Merge pull request #1483 from BradGroux/fix/task-reopen-cleanup-1482

fix: cancel stale task close cleanup on reopen
This commit is contained in:
Brad Groux 2026-09-04 08:52:57 -05:00 • committed by GitHub
commit 457e024f6e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 69 additions and 3 deletions

View file

@ -0,0 +1,7 @@
# Task close and reopen lifecycle
Closing a task retains its selected record briefly for the exit transition. That cleanup belongs to the closed state and is cancelled when any task opens again or the Board unmounts. Both history-driven dismissal and direct close use this lifecycle.
An earlier unowned timeout could clear a newly opened task after a rapid close/reopen. Reduced-motion navigation made the race particularly visible. Regression coverage closes a task, immediately opens the same or a different task, and confirms an edit saves while the new workspace stays visible. It does not add a delay between close and reopen.
This lifecycle does not change browser-history entries, task mutation semantics, exit timing, or opener focus restoration. Packaged task-workspace checks exercise the same rapid transition alongside retained section, scroll, pending edit, chat draft, and historical-attempt selection.

54
e2e/task-reopen.spec.ts Normal file
View file

@ -0,0 +1,54 @@
import { test, expect } from '@playwright/test';
import { bypassAuth, seedTestTask, deleteTask, cleanupRoutes } from './helpers/auth';
for (const target of ['same', 'different'] as const) {
test(`the ${target} task remains editable when opened immediately after closing`, async ({
page,
}) => {
await bypassAuth(page);
await page.emulateMedia({ reducedMotion: 'reduce' });
const task = await seedTestTask(page, { title: 'Immediate reopen fixture', status: 'todo' });
const id = (task as { id: string }).id;
let targetId = id;
try {
if (target === 'different') {
const other = await seedTestTask(page, {
title: 'Different reopen fixture',
status: 'todo',
});
targetId = (other as { id: string }).id;
}
await page.goto('/');
const card = page.locator(`[data-task-id="${id}"]`);
const detail = page.getByTestId('task-detail-panel');
await card.focus();
await card.press('Enter');
await expect(detail).toBeVisible();
await detail.getByRole('button', { name: 'Close task workspace', exact: true }).click();
await expect(detail).toHaveCount(0);
const nextCard = page.locator(`[data-task-id="${targetId}"]`);
await nextCard.focus();
await nextCard.press('Enter');
const title = detail.getByRole('textbox', { name: 'Task title', exact: true });
await expect(title).toHaveValue(
target === 'same' ? 'Immediate reopen fixture' : 'Different reopen fixture'
);
const [saved] = await Promise.all([
page.waitForResponse(
(response) =>
new URL(response.url()).pathname === `/api/tasks/${targetId}` &&
response.request().method() === 'PATCH',
{ timeout: 5000 }
),
title.fill('Reopened task stays editable'),
]);
expect(saved.ok()).toBeTruthy();
await expect(detail).toBeVisible();
await expect(title).toHaveValue('Reopened task stays editable');
} finally {
if (targetId !== id) await deleteTask(page, targetId);
await deleteTask(page, id);
await cleanupRoutes(page);
}
});
}

View file

@ -296,12 +296,19 @@ export function KanbanBoard() {
const handlePopState = () => {
if (!detailOpen || taskIdFromHistory() === selectedTask?.id) return;
setDetailOpen(false);
setTimeout(() => setSelectedTask(null), 200);
};
window.addEventListener('popstate', handlePopState);
return () => window.removeEventListener('popstate', handlePopState);
}, [detailOpen, selectedTask?.id]);
// Retain the closing task for its exit transition, but cancel that cleanup
// when a task is reopened before the transition finishes.
useEffect(() => {
if (detailOpen || !selectedTask) return;
const timer = window.setTimeout(() => setSelectedTask(null), 200);
return () => window.clearTimeout(timer);
}, [detailOpen, selectedTask]);
// Apply the configured default saved view only when the current URL has no board filters.
useEffect(() => {
if (defaultSavedViewAppliedRef.current || isPlaceholderData) return;
@ -558,8 +565,6 @@ export function KanbanBoard() {
}
setDetailOpen(open);
if (!open) {
// Small delay to allow animation to complete
setTimeout(() => setSelectedTask(null), 200);
returnFromTask();
}
};