fix(flows): stop the pager bouncing the user back to the flows list

Stepping to a sibling flow landed on /flows. The detail page inferred "this
flow does not exist" from three absences — not loading, no flow, no error —
and an Apollo variables change satisfies all three: it reports networkStatus
setVariables, not loading, while the new flow's data is still undefined.
Instrumenting history showed both hops: replaceState to /flows/2861, then
straight back out to /flows.

The provider now publishes a positive isFlowMissing (the query settled with
no flow, or failed as a not-found) and the redirect reads only that, so
retuning the loading flag cannot silently break navigation again — which is
how this shipped. isLoading itself becomes "in flight with nothing to show",
which also keeps the Retry button on a failed load from ejecting the user.

Nothing in the suite pressed Prev or Next, so the new spec does: it samples
the DOM through a delayed fetch, proving the URL never passes through the
list and the pager stays mounted while the sibling loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-22 16:05:22 +07:00
co-authored by Claude Opus 4.8
parent 3d04fcfff2
commit 5b4ded1e3c
3 changed files with 181 additions and 104 deletions
+75
View File
@@ -0,0 +1,75 @@
import { expect, test } from '../../fixtures/test.ts';
import { expectCleanPage } from '../../helpers/errors.ts';
import { flowsCassette, flowTabsCassette } from '../../mocks/cassettes/flows.ts';
// Nothing else in the suite presses Prev/Next, which is how a redirect that fired mid-switch
// shipped: every other flow spec reaches a detail page through the list.
test.describe('flow pager', { tag: ['@flows', '@smoke'] }, () => {
test.use({ cassette: flowsCassette() });
test.describe('with a report to show', () => {
test.use({ cassette: flowTabsCassette() });
// Not covered by the pixel baselines: the header cluster is well under the visual
// project's maxDiffPixelRatio, so a reordering passes there unnoticed.
test('keeps the variable action left of the fixed ones', async ({ page }) => {
await page.goto('/flows/5');
await expect(page.locator('header').getByRole('button', { name: 'Report' })).toBeVisible();
const labels = await page
.locator('header button')
.evaluateAll((buttons) => buttons.map((button) => button.getAttribute('aria-label') ?? ''));
const positionOf = (label: string) => labels.findIndex((candidate) => candidate.startsWith(label));
// Report comes and goes with the task list; anchored leftmost, its arrival cannot shift
// the controls that are always there.
expect(positionOf('Report')).toBeLessThan(positionOf('Toggle favorite'));
expect(positionOf('Toggle favorite')).toBeLessThan(positionOf('Previous'));
expect(positionOf('Next')).toBeLessThan(positionOf('Flow actions'));
});
});
test('steps to the sibling flow and back without passing through the list', async ({ page, pageErrorLog }) => {
const header = page.locator('header');
await page.goto('/flows/5');
await expect(header.getByText('E2E Alpha')).toBeVisible();
// Hold the sibling's data back so the in-flight window is wide enough to sample: the
// cassette answers in single-digit milliseconds, which hides an unmount entirely.
await page.route('**/graphql', async (route) => {
await new Promise((resolve) => setTimeout(resolve, 300));
await route.fallback();
});
await header.getByRole('button', { name: 'Next' }).click();
const samples = await page.evaluate(async () => {
const taken = [];
for (let index = 0; index < 10; index += 1) {
taken.push({
hasPager: !!document.querySelector('header button[aria-label="Next"]'),
path: window.location.pathname,
});
await new Promise((resolve) => setTimeout(resolve, 30));
}
return taken;
});
await expect(page).toHaveURL(/\/flows\/6$/);
await expect(header.getByText('E2E Beta')).toBeVisible();
// Sampled rather than awaited: `toBeVisible` retries, so it passes even if the cluster
// unmounts for the length of the fetch and comes back — which is what gating the pager
// on the loaded flow did, costing the user a round trip per step.
expect(samples.every((sample) => sample.hasPager)).toBe(true);
expect(samples.map((sample) => sample.path)).not.toContain('/flows');
await header.getByRole('button', { name: 'Previous' }).click();
await expect(page).toHaveURL(/\/flows\/5$/);
expectCleanPage(pageErrorLog);
});
});
+95 -97
View File
@@ -80,7 +80,7 @@ function Flow() {
const { isDesktop, isMobile } = useBreakpoint();
const navigate = useNavigate();
const { flowData, flowId, flowLoadError, isLoading: isFlowLoading, refetchFlow } = useFlow();
const { flowData, flowId, flowLoadError, isFlowMissing, isLoading: isFlowLoading, refetchFlow } = useFlow();
const { deleteFlow, finishFlow } = useFlows();
const { isFavoriteFlow, toggleFavoriteFlow } = useFavorites();
@@ -109,13 +109,10 @@ function Flow() {
const [renameFlowMutation, { loading: isRenameLoading }] = useMutation(RenameFlowDocument);
useEffect(() => {
// Redirect only when the flow is genuinely gone (query resolved with no flow, or
// a not-found error). A real load failure keeps the user here behind the
// ErrorState + Retry below instead of silently bouncing to the list.
if (!isFlowLoading && !flowData?.flow && !flowLoadError) {
if (isFlowMissing) {
navigate(routes.flows, { replace: true });
}
}, [flowData, flowLoadError, isFlowLoading, navigate]);
}, [isFlowMissing, navigate]);
const handleFlowRenameSave = useCallback(async () => {
const newTitle = editingInputRef.current?.value.trim();
@@ -278,24 +275,14 @@ function Flow() {
</BreadcrumbList>
</Breadcrumb>
</AppHeaderContent>
<AppHeaderActions
pager={
flow &&
!isMobile && (
<DetailNavigationToolbar<FlowItem>
controller={flowNav}
renderItem={renderFlowItem}
sheetIcon={<GitFork className="size-4" />}
sheetTitle="Flows"
/>
)
}
>
<AppHeaderActions>
{!!(flowData?.tasks ?? [])?.length && <FlowReportDropdown />}
{flowId && !isMobile && (
<Button
aria-label="Toggle favorite"
aria-pressed={isFavoriteFlow(flowId)}
className="shrink-0"
disabled={isFlowLoading}
onClick={() => toggleFavoriteFlow(flowId)}
size="icon"
variant="ghost"
@@ -303,101 +290,112 @@ function Flow() {
<Star className={isFavoriteFlow(flowId) ? 'fill-yellow-500 stroke-yellow-500' : ''} />
</Button>
)}
{!!(flowData?.tasks ?? [])?.length && <FlowReportDropdown />}
{flow && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Flow actions"
className="size-8 p-0"
variant="ghost"
>
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-24"
onCloseAutoFocus={handleDropdownCloseAutoFocus}
{!isMobile && (
<DetailNavigationToolbar<FlowItem>
controller={flowNav}
renderItem={renderFlowItem}
sheetIcon={<GitFork className="size-4" />}
sheetTitle="Flows"
/>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
aria-label="Flow actions"
className="size-8 p-0"
variant="ghost"
>
{isMobile && flowNav.total > 0 && (
<>
{/* onSelect={preventDefault} stops the Radix menu from closing on label
<Ellipsis />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
className="min-w-24"
onCloseAutoFocus={handleDropdownCloseAutoFocus}
>
{isMobile && (
<>
{/* onSelect={preventDefault} stops the Radix menu from closing on label
clicks; DetailNavigationButtons owns its own click handlers. */}
<DropdownMenuItem
className="cursor-default hover:bg-transparent focus:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
<GitFork />
Flows
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
<DetailNavigationButtons<FlowItem>
controller={flowNav}
sheetTitle="Flows"
size="sm"
/>
</div>
</DropdownMenuItem>
{flowId && (
<DropdownMenuItem onClick={() => toggleFavoriteFlow(flowId)}>
<Star
className={
isFavoriteFlow(flowId)
? 'size-4 fill-yellow-500 stroke-yellow-500'
: 'size-4'
}
/>
{isFavoriteFlow(flowId) ? 'Remove from favorites' : 'Add to favorites'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem onClick={handleFlowRenameStart}>
<PencilLine className="size-3" />
Rename
</DropdownMenuItem>
{isFlowRunning && (
<DropdownMenuItem
disabled={isFinishing}
onClick={() => handleFlowFinish()}
className="cursor-default hover:bg-transparent focus:bg-transparent"
onSelect={(event) => event.preventDefault()}
>
{isFinishing ? (
<>
<Spinner variant="circle" />
Finishing...
</>
) : (
<>
<Pause />
Finish
</>
)}
<GitFork />
Flows
<div className="-my-1.5 -mr-2 ml-auto flex items-center">
<DetailNavigationButtons<FlowItem>
controller={flowNav}
sheetTitle="Flows"
size="sm"
/>
</div>
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
{flowId && (
<DropdownMenuItem
disabled={isFlowLoading}
onClick={() => toggleFavoriteFlow(flowId)}
>
<Star
className={
isFavoriteFlow(flowId)
? 'size-4 fill-yellow-500 stroke-yellow-500'
: 'size-4'
}
/>
{isFavoriteFlow(flowId) ? 'Remove from favorites' : 'Add to favorites'}
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
</>
)}
<DropdownMenuItem
disabled={isFlowLoading}
onClick={handleFlowRenameStart}
>
<PencilLine className="size-3" />
Rename
</DropdownMenuItem>
{isFlowRunning && (
<DropdownMenuItem
disabled={isDeleting}
onClick={() => setIsDeleteDialogOpen(true)}
disabled={isFinishing}
onClick={() => handleFlowFinish()}
>
{isDeleting ? (
{isFinishing ? (
<>
<Spinner variant="circle" />
Deleting...
Finishing...
</>
) : (
<>
<Trash />
Delete
<Pause />
Finish
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
)}
<DropdownMenuSeparator />
<DropdownMenuItem
disabled={isDeleting || isFlowLoading}
onClick={() => setIsDeleteDialogOpen(true)}
>
{isDeleting ? (
<>
<Spinner variant="circle" />
Deleting...
</>
) : (
<>
<Trash />
Delete
</>
)}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</AppHeaderActions>
</AppHeader>
{isMobile && flow && (
{isMobile && (
<DetailNavigationSheet<FlowItem>
controller={flowNav}
renderItem={renderFlowItem}
+11 -7
View File
@@ -1,4 +1,3 @@
import { NetworkStatus } from '@apollo/client';
import { useMutation, useQuery, useSubscription } from '@apollo/client/react';
import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
import { useParams } from 'react-router-dom';
@@ -50,6 +49,7 @@ interface FlowContextValue {
flowStatus: StatusType | undefined;
initiateAssistantCreation: () => void;
isAssistantsLoading: boolean;
isFlowMissing: boolean;
isLoading: boolean;
refetchFlow: () => void;
selectAssistant: (assistantId: null | string) => void;
@@ -75,7 +75,6 @@ export function FlowProvider({ children }: FlowProviderProps) {
data: flowData,
error: flowError,
loading,
networkStatus,
refetch: refetchFlow,
} = useQuery(FlowDocument, {
errorPolicy: 'all',
@@ -86,17 +85,20 @@ export function FlowProvider({ children }: FlowProviderProps) {
variables: { id: flowId ?? '' },
});
// Only the initial load blocks the UI and gates subscriptions. A background
// refetch (e.g. the reconnect reconcile) stays at networkStatus `refetch`, so
// it must NOT flip isLoading — otherwise it covers the page with the spinner
// overlay and tears down the 14 live subscriptions mid-flight.
const isLoading = loading && networkStatus === NetworkStatus.loading;
// In flight with nothing to show. A background refetch (the reconnect reconcile) still
// holds the previous flow, so it must not raise this: it would cover the page with the
// spinner overlay and tear down the 14 live subscriptions mid-flight.
const isLoading = loading && !flowData?.flow;
// 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;
// Absence of data is not absence of the flow — it is also what an in-flight switch to
// another flow looks like, so this reads only settled outcomes.
const isFlowMissing = Boolean(flowData && !flowData.flow) || Boolean(flowError && isFlowNotFoundError(flowError));
const { data: assistantsData, loading: isAssistantsLoading } = useQuery(AssistantsDocument, {
fetchPolicy: 'cache-first',
nextFetchPolicy: 'cache-first',
@@ -397,6 +399,7 @@ export function FlowProvider({ children }: FlowProviderProps) {
flowStatus,
initiateAssistantCreation,
isAssistantsLoading,
isFlowMissing,
isLoading,
refetchFlow,
selectAssistant,
@@ -417,6 +420,7 @@ export function FlowProvider({ children }: FlowProviderProps) {
flowStatus,
initiateAssistantCreation,
isAssistantsLoading,
isFlowMissing,
isLoading,
refetchFlow,
selectAssistant,