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
202 changes: 202 additions & 0 deletions propr-ui/e2e/list-search-lazy-navigation.pw.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,202 @@
import { expect, test, type Locator, type Page } from '@playwright/test';
import { capture, now, ago, tasks } from './task-list-desktop.fixture';

// A list search that is cleared just before the user leaves must not navigate back to the
// list while the destination's lazy route chunk is still loading. The departing list stays
// mounted during that transition, with its debounced search write still pending.

const QUERY = 'propr-validation-no-match';
// Longer than every list's search debounce (Tasks/Goals 400ms, Plans 300ms).
const PAST_DEBOUNCE_MS = 1_000;

const goal = (id: string, title: string) => ({
id, owner: 'operator', repository: 'integry/propr', title, objective: `${title} for the release.`,
launchStrategy: 'orchestrate', initialPrompt: `/goal ${title}`, attachments: [],
baseBranch: null, branchName: `goal/${id}`, worktreePath: `/tmp/${id}`,
agent: { id: 'agent-1', alias: 'codex', type: 'codex' },
requestedModel: 'gpt-6-astra', effectiveModel: 'gpt-6-astra',
maxParallelTasks: 3, ultrafix: true, desiredState: 'running', resultState: null,
failureReason: null, pausePending: false,
control: { requestGeneration: 0, acknowledgedGeneration: 0, pending: false },
taskId: `${id}-task`, sessionId: null, conversationId: null, finalPr: null,
checkpoint: null, artifacts: [],
artifactStats: { issues: 0, openIssues: 0, pullRequests: 0, openPullRequests: 0 },
liveSummary: { currentTask: null, todos: [], tokenUsage: { input_tokens: 0, output_tokens: 0 }, nativeGoal: null },
taskState: 'claude_execution', createdAt: ago(60), updatedAt: ago(5),
startedAt: ago(60), pausedAt: null, completedAt: null,
elapsedMs: 60_000, activeMs: 60_000, pausedMs: 0,
});
const goals = [goal('goal-1', 'Launch Customer Analytics Dashboard'), goal('goal-2', 'Prepare Billing API')];
const drafts = [
{ draft_id: 'draft-1', repository: 'integry/propr', name: 'Mobile search for list pages', initial_prompt: 'Mobile search', status: 'draft', updated_at: ago(30), created_at: ago(90) },
];

const matches = (text: string, search: string | null) => !search || text.toLowerCase().includes(search.toLowerCase());

async function stub(page: Page) {
await page.clock.install({ time: now });
await page.routeWebSocket('**/socket.io/**', socket => socket.close());
await page.route('**/api/**', route => {
const url = new URL(route.request().url());
const search = url.searchParams.get('search');
const responses: Record<string, unknown> = {
'/api/auth/demo-mode': { demoMode: false },
'/api/auth/user': { id: 'user-1', login: 'operator', username: 'operator', displayName: 'Operations', email: null, avatarUrl: null, role: 'admin', permissions: [], authorizationSource: 'local' },
'/api/instance/catalog': { agents: [{ id: 'agent-1', alias: 'codex', type: 'codex', enabled: true, models: ['gpt-6-astra'], defaultModel: 'gpt-6-astra' }], repositories: [{ name: 'integry/propr', enabled: true }] },
'/api/tasks': (() => {
const found = tasks.filter(task => matches(task.title, search));
return { tasks: found, total: found.length, totalRuns: found.length };
})(),
'/api/stats/repositories': { repositories: [{ repository: 'integry/propr', total: 30, completed: 20, failed: 2, inProgress: 1, successRate: 80 }] },
'/api/planner/drafts': (() => {
const found = drafts.filter(item => matches(item.name, search));
return { drafts: found, total: found.length, page: 1, limit: 50, hasMore: false };
})(),
'/api/planner/drafts/repositories': { repositories: [{ repo: 'integry/propr', count: 1 }], total: 1 },
'/api/goals': { goals },
'/api/queue/stats': { active: 0, waiting: 0, completed: 0, failed: 0 },
'/api/stats/generating-plans': { count: 0 },
'/api/notifications/unread-count': { unreadCount: 0 },
'/api/notifications/preferences': { preferences: {}, quietHours: { start: null, end: null, timezone: 'UTC' }, badgeEnabled: false },
};
return url.pathname in responses
? route.fulfill({ json: responses[url.pathname] })
: route.fulfill({ status: 503, json: { error: 'Unavailable in lazy navigation fixture' } });
});
}

/**
* Holds the real built Settings route chunk until `release` is called, then lets the
* original, unchanged response through.
*/
async function holdSettingsChunk(page: Page) {
let release!: () => void;
const released = new Promise<void>(resolve => { release = resolve; });
let requested!: () => void;
const wasRequested = new Promise<void>(resolve => { requested = resolve; });
await page.route(/\/assets\/SettingsPage-[\w-]+\.js$/, async route => {
requested();
await released;
await route.continue();
});
return { wasRequested, release };
}

/** Stops the page's timers (the installed clock) from here until the test advances them. */
const pauseTimers = async (page: Page) => page.clock.pauseAt(await page.evaluate(() => Date.now()) + 50);

const routePath = (page: Page) => page.evaluate(() => window.location.pathname);
const routeSearch = (page: Page) => page.evaluate(() => window.location.search);

// Goals and Plans write a cleared search at once; Tasks writes it through its debounce.
const lists = [
{ name: 'Tasks', path: '/tasks', label: 'Search tasks', clearsAtOnce: false },
{ name: 'Plans', path: '/plans', label: 'Search plans', clearsAtOnce: true },
{ name: 'Goals', path: '/goals', label: 'Search goals', clearsAtOnce: true },
] as const;

const viewports = [
{ name: '320', width: 320, height: 640, phone: true },
{ name: '390', width: 390, height: 844, phone: true },
{ name: 'desktop', width: 1280, height: 800, phone: false },
] as const;

// The web build's BrowserRouter. The native HashRouter only exists in the desktop
// renderer build; hooks.lazyRoute.test.tsx covers it with the same lazy transition.
for (const viewport of viewports) {
test.describe(`${viewport.name}`, () => {
test.use({ hasTouch: viewport.phone, isMobile: viewport.phone });
test.beforeEach(async ({ page }) => {
await page.setViewportSize({ width: viewport.width, height: viewport.height });
await stub(page);
});

const press = async (locator: Locator) => (viewport.phone ? locator.tap() : locator.click());
const openSettings = async (page: Page) => {
if (viewport.phone) {
await press(page.getByRole('navigation', { name: 'Primary navigation' }).getByRole('button', { name: 'More' }));
await press(page.getByRole('dialog', { name: 'ProPR', exact: true }).getByRole('link', { name: 'Settings', exact: true }));
} else {
await press(page.locator('aside').first().getByRole('link', { name: 'Settings', exact: true }));
}
};
const visit = async (page: Page, path: string) => {
await page.goto(path);
await expect.poll(() => routePath(page)).toBe(path);
};

/** Leaves for Settings while its chunk is held, and checks the list never takes the URL back. */
/**
* Leaves for Settings while its chunk is held, with the page's timers paused since the
* list edit, so a pending debounce is certain to still be queued when the route changes.
*/
const leaveForDelayedSettings = async (page: Page, list: typeof lists[number], chunk: Awaited<ReturnType<typeof holdSettingsChunk>>, preview?: string) => {
await openSettings(page);
await chunk.wasRequested;
await expect.poll(() => routePath(page)).toBe('/settings');
// Every list search debounce elapses while the destination is still loading.
await page.clock.runFor(PAST_DEBOUNCE_MS);
expect(await routePath(page)).toBe('/settings');
await page.clock.resume();

chunk.release();
await expect(page.getByRole('heading', { name: 'Settings', exact: true }).first()).toBeVisible();
expect(await routePath(page)).toBe('/settings');
await page.waitForTimeout(PAST_DEBOUNCE_MS);
expect(await routePath(page)).toBe('/settings');
await expect(page.getByRole('textbox', { name: list.label })).toHaveCount(0);
if (preview) await capture(page, preview);

// Back still returns to the list, without the cleared search.
await page.goBack();
await expect.poll(() => routePath(page)).toBe(list.path);
await expect(page.getByRole('textbox', { name: list.label })).toHaveValue('');
expect(await routeSearch(page)).not.toContain('search=');
};

for (const list of lists) {
test(`${list.name}: a cleared search does not pull navigation back while Settings loads`, async ({ page }) => {
const chunk = await holdSettingsChunk(page);
await visit(page, list.path);
const search = page.getByRole('textbox', { name: list.label });
await expect(search).toBeVisible();

await press(search);
await page.keyboard.type(QUERY);
await expect.poll(() => routeSearch(page)).toContain(`search=${QUERY}`);
// Anything the clear leaves queued (Goals re-commits its debounced value) is still pending on leaving.
if (list.clearsAtOnce) await pauseTimers(page);
await press(page.getByRole('button', { name: 'Clear search', exact: true }));
await expect(search).toHaveValue('');
await expect(search).toBeFocused();
await expect.poll(() => routeSearch(page)).not.toContain('search=');
if (!list.clearsAtOnce) await pauseTimers(page);

await leaveForDelayedSettings(page, list, chunk, list.name === 'Goals' ? `lazy-settings-after-goals-clear-${viewport.name}` : undefined);
});

test(`${list.name}: a typed search still pending does not pull navigation back while Settings loads`, async ({ page }) => {
const chunk = await holdSettingsChunk(page);
await visit(page, list.path);
const search = page.getByRole('textbox', { name: list.label });
await expect(search).toBeVisible();

// Leave before the debounce commits the query. The departing list must drop it.
await press(search);
await pauseTimers(page);
await page.keyboard.type(QUERY);
await expect(search).toHaveValue(QUERY);
expect(await routeSearch(page)).not.toContain('search=');
await leaveForDelayedSettings(page, list, chunk);
});
}

test('control: with no list edit, Settings still loads behind the held chunk', async ({ page }) => {
const chunk = await holdSettingsChunk(page);
await visit(page, '/goals');
await expect(page.getByRole('textbox', { name: 'Search goals' })).toBeVisible();
await pauseTimers(page);
await leaveForDelayedSettings(page, lists[2], chunk);
});
});
}
4 changes: 2 additions & 2 deletions propr-ui/src/components/TaskList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import {
groupTasksForDisplay,
selectValue,
} from './TaskList/utils';
import { useDebouncedCallback, useTaskSearchParams } from './TaskList/hooks';
import { useDebouncedCallback, useListSearchParams } from './TaskList/hooks';
import { isDialogOpen, isTypingTarget } from './TaskList/keyboardOwnership';
import { useLiveRefreshScheduler } from '../hooks/useLiveRefreshScheduler';
import type { TaskUpdatePayload } from '@propr/shared';
Expand Down Expand Up @@ -141,7 +141,7 @@ function useRowKeyboardNavigation(
}

const TaskList: React.FC<TaskListProps> = ({ limit, showViewAll = false, hideFilters = false, selectedTaskId = null, onSelectTask, refreshKey = 0, onGroupsChange }) => {
const [searchParams, setSearchParams] = useTaskSearchParams();
const [searchParams, setSearchParams] = useListSearchParams();
const navigate = useNavigate();
const { onTaskUpdate, isConnected } = useSocket();
const currentUser = useCurrentUser();
Expand Down
112 changes: 112 additions & 0 deletions propr-ui/src/components/TaskList/hooks.lazyRoute.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import React, { Suspense, lazy, useCallback, useState } from 'react';
import { BrowserRouter, HashRouter, Link, Route, Routes } from 'react-router-dom';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useDebouncedCallback, useListSearchParams } from './hooks';

// A departing list stays mounted while a lazy destination route loads (React Router
// renders navigations in a transition). Its debounced search write must not take the
// URL back to the list.

/** The Goals/Plans/Tasks search shape: instant input, debounced URL write, immediate clear. */
const SearchList: React.FC = () => {
const [params, setParams] = useListSearchParams();
const [query, setQuery] = useState(params.get('search') ?? '');
const commit = useCallback((value: string) => {
setParams(current => {
const next = new URLSearchParams(current);
if (value) next.set('search', value);
else next.delete('search');
return next;
}, { replace: true });
}, [setParams]);
useDebouncedCallback(query, commit, 400);
return (
<>
<h1>List</h1>
<input aria-label="Search" value={query} onChange={event => setQuery(event.target.value)} />
<button type="button" onClick={() => { setQuery(''); commit(''); }}>Clear search</button>
<Link to="/settings">Settings</Link>
</>
);
};

const routerLocation = (router: 'browser' | 'hash') => {
if (router === 'hash') return window.location.hash.slice(1) || '/';
return `${window.location.pathname}${window.location.search}`;
};

const renderApp = (router: 'browser' | 'hash') => {
let release!: () => void;
const chunk = new Promise<{ default: React.FC }>(resolve => {
release = () => resolve({ default: () => <h1>Settings</h1> });
});
const Settings = lazy(() => chunk);
const Router = router === 'hash' ? HashRouter : BrowserRouter;
render(
<Router>
<Suspense fallback={<p>Loading</p>}>
<Routes>
<Route path="/goals" element={<SearchList />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Suspense>
</Router>,
);
return { release: () => act(async () => { release(); await chunk; }) };
};

describe.each(['browser', 'hash'] as const)('list search during a lazy route transition (%s router)', router => {
beforeEach(() => {
vi.useFakeTimers({ shouldAdvanceTime: true });
window.history.replaceState(null, '', router === 'hash' ? '/#/goals' : '/goals');
});
afterEach(() => {
vi.useRealTimers();
window.history.replaceState(null, '', '/');
});

const leaveWhileSettingsLoads = async (release: () => Promise<void>) => {
fireEvent.click(screen.getByRole('link', { name: 'Settings' }));
expect(routerLocation(router)).toBe('/settings');
// The departing list is still on screen, and every debounce elapses.
expect(screen.getByRole('heading', { name: 'List' })).toBeTruthy();
await act(async () => { vi.advanceTimersByTime(1_000); });
expect(routerLocation(router)).toBe('/settings');

await release();
expect(await screen.findByRole('heading', { name: 'Settings' })).toBeTruthy();
await act(async () => { vi.advanceTimersByTime(1_000); });
expect(routerLocation(router)).toBe('/settings');
};

it('a cleared search does not navigate back to the list', async () => {
const { release } = renderApp(router);
fireEvent.change(screen.getByRole('textbox', { name: 'Search' }), { target: { value: 'no-match' } });
await act(async () => { vi.advanceTimersByTime(400); });
expect(routerLocation(router)).toBe('/goals?search=no-match');

fireEvent.click(screen.getByRole('button', { name: 'Clear search' }));
expect(routerLocation(router)).toBe('/goals');
await leaveWhileSettingsLoads(release);
});

it('a typed search still pending does not navigate back to the list', async () => {
const { release } = renderApp(router);
fireEvent.change(screen.getByRole('textbox', { name: 'Search' }), { target: { value: 'no-match' } });
await leaveWhileSettingsLoads(release);
});

it('control: with no edit, the delayed destination opens', async () => {
const { release } = renderApp(router);
await leaveWhileSettingsLoads(release);
});

it('still writes the search while the list stays open', async () => {
renderApp(router);
fireEvent.change(screen.getByRole('textbox', { name: 'Search' }), { target: { value: 'billing' } });
expect(routerLocation(router)).toBe('/goals');
await act(async () => { vi.advanceTimersByTime(400); });
expect(routerLocation(router)).toBe('/goals?search=billing');
});
});
10 changes: 6 additions & 4 deletions propr-ui/src/components/TaskList/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,13 @@ import { useCallback, useContext, useEffect, useLayoutEffect, useRef } from 'rea
import { UNSAFE_NavigationContext, createSearchParams, useLocation, useNavigate, useSearchParams, type Location, type Navigator, type SetURLSearchParams } from 'react-router-dom';

/**
* Task filters and selection share this write boundary. React Router's normal
* functional setter reads render-time params, not the current history entry.
* History can already have advanced while a transition is still rendering.
* Task, Plan and Goal filters and selection share this write boundary. React
* Router's normal functional setter reads render-time params, not the current
* history entry. History can already have advanced while a transition is still
* rendering, e.g. while a lazy destination route loads and the departing list
* stays mounted with its debounced search still pending.
*/
export function useTaskSearchParams(): ReturnType<typeof useSearchParams> {
export function useListSearchParams(): ReturnType<typeof useSearchParams> {
const [params] = useSearchParams();
const origin = useLocation();
const navigate = useNavigate();
Expand Down
2 changes: 1 addition & 1 deletion propr-ui/src/hooks/useInboxSelection.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { useInboxSelection } from './useInboxSelection';

// The write boundary refuses every write, as it does once history has moved on before the URL write.
vi.mock('../components/TaskList/hooks', () => ({
useTaskSearchParams: () => [useSearchParams()[0], () => undefined],
useListSearchParams: () => [useSearchParams()[0], () => undefined],
}));

const wrapper = ({ children }: { children: React.ReactNode }) => (
Expand Down
4 changes: 2 additions & 2 deletions propr-ui/src/hooks/useInboxSelection.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { useTaskSearchParams } from '../components/TaskList/hooks';
import { useListSearchParams } from '../components/TaskList/hooks';

/** Tailwind `xl`: wide enough to show the queue and a triage verdict side by side. */
export const INBOX_SPLIT_QUERY = '(min-width: 1280px)';
Expand All @@ -23,7 +23,7 @@ export interface InboxSelection {
* through the queue with j/k does not fill the back button.
*/
export function useInboxSelection(): InboxSelection {
const [searchParams, setSearchParams] = useTaskSearchParams();
const [searchParams, setSearchParams] = useListSearchParams();
const urlId = searchParams.get('notification') || null;
// The router applies a URL change inside a transition, so for a moment the
// URL is ahead of what renders. The latest choice is held here until the
Expand Down
4 changes: 2 additions & 2 deletions propr-ui/src/hooks/useTaskSelection.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useTaskSearchParams } from '../components/TaskList/hooks';
import { useListSearchParams } from '../components/TaskList/hooks';

/** Tailwind `xl`: wide enough to show the task list and a task side by side. */
export const TASK_SPLIT_QUERY = '(min-width: 1280px)';
Expand All @@ -22,7 +22,7 @@ export interface TaskSelection {
* stepping through tasks with j/k does not fill the back button.
*/
export function useTaskSelection(): TaskSelection {
const [searchParams, setSearchParams] = useTaskSearchParams();
const [searchParams, setSearchParams] = useListSearchParams();
const urlTaskId = searchParams.get('task') || null;
// The router applies a URL change inside a transition, so for a moment the
// URL is ahead of what renders. The latest choice is held here until the
Expand Down
Loading
Loading