👻 fix: Clear Project-Scoped Landing When the Selected Project Is Deleted (#13525)

* fix(projects): clear landing scope when the selected project is deleted

When a project-scoped new-chat landing (/c/new?projectId=...) was open and the
project got deleted, the chip kept showing the dead project and sends targeted it
(saving unscoped with a visual glitch).

- ChatRoute: only trust the scope when the project query succeeds (isSuccess), so
  React Query's retained-on-error data can't keep a deleted project's chip alive;
  strip ?projectId once the query settles to not-found so the landing reverts to a
  normal unscoped chat.
- useDeleteProjectMutation: invalidate the project-detail query instead of removing
  it, so active observers refetch and settle into an error state (removing left them
  stuck loading under refetchOnMount: false).
- e2e: regression test for delete-while-scoped.

Fixes a follow-up issue to the projects feature (#13467).

* fix(projects): only drop scope on definitive not-found; clear inactive deleted detail

Address Codex review on #13525:
- ChatRoute: gate scope removal on a 404 (isNotFoundError) or a success that
  resolves to a different/empty project, so a transient (non-404) failure under
  retry:false no longer unscopes a valid project; keep the chip through transient
  errors via retained data.
- useDeleteProjectMutation: also removeQueries({ type: 'inactive' }) so a deleted
  project's inactive cached detail is dropped and a later visit refetches into a
  not-found state instead of rendering stale cache within cacheTime.
This commit is contained in:
Danny Avila 2026-06-05 10:19:58 -04:00 committed by GitHub
parent 40ec77e061
commit 28e937a422
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 74 additions and 3 deletions

View file

@ -65,7 +65,13 @@ export const useDeleteProjectMutation = (): UseMutationResult<
return useMutation((projectId: string) => dataService.deleteProject(projectId), {
onSuccess: (_result, projectId) => {
clearActiveConversationProject(projectId);
queryClient.removeQueries([QueryKeys.project, projectId]);
// Invalidate so an *active* project-detail observer refetches and settles into a
// not-found state — consumers (e.g. ChatRoute) can then react to the deletion.
// (Removing it instead leaves observers stuck loading under `refetchOnMount: false`.)
queryClient.invalidateQueries([QueryKeys.project, projectId]);
// Drop any *inactive* cached detail so a later visit to the deleted project
// refetches (→ not-found) rather than rendering stale cache within `cacheTime`.
queryClient.removeQueries([QueryKeys.project, projectId], { type: 'inactive' });
queryClient.invalidateQueries([QueryKeys.projects]);
queryClient.invalidateQueries([QueryKeys.allConversations]);
},

View file

@ -55,7 +55,7 @@ export default function ChatRoute() {
useAppStartup({ startupConfig, user });
const index = 0;
const [searchParams] = useSearchParams();
const [searchParams, setSearchParams] = useSearchParams();
const { conversationId = '' } = useParams();
const projectIdParam = searchParams.get('projectId');
const chatProjectId = isValidChatProjectId(projectIdParam) ? projectIdParam : null;
@ -70,12 +70,50 @@ export default function ChatRoute() {
staleTime: 30000,
cacheTime: 300000,
});
const verifiedChatProjectId = projectQuery.data?._id === chatProjectId ? chatProjectId : null;
/**
* The scoped project is *confirmed gone* a not-found/not-owned (404) response,
* or a success that resolved to a different/empty project. Transient failures
* (500, network, auth refresh race) are deliberately excluded: this query runs with
* `retry: false`, so treating any error as "gone" would unscope a valid project on
* a single blip.
*/
const projectNotFound = projectQuery.isError && isNotFoundError(projectQuery.error);
/**
* Trust the scope when the project resolves to itself, and keep showing it through
* transient errors via React Query's retained data but never for a project that
* is confirmed gone (otherwise the deleted project's chip lingers).
*/
const verifiedChatProjectId =
!projectNotFound && projectQuery.data?._id === chatProjectId ? chatProjectId : null;
const projectTemplate = useMemo(
() => (verifiedChatProjectId ? { chatProjectId: verifiedChatProjectId } : {}),
[verifiedChatProjectId],
);
/**
* The scoped project is gone even though the URL still carries `?projectId`. Drop
* the param so the new-chat landing reverts to an unscoped chat otherwise the
* stale chip lingers and sends target a dead project.
*/
const projectScopeMissing =
Boolean(chatProjectId) &&
conversationId === Constants.NEW_CONVO &&
(projectNotFound || (projectQuery.isSuccess && projectQuery.data?._id !== chatProjectId));
useEffect(() => {
if (!projectScopeMissing) {
return;
}
setSearchParams(
(params) => {
const next = new URLSearchParams(params);
next.delete('projectId');
return next;
},
{ replace: true },
);
}, [projectScopeMissing, setSearchParams]);
const modelsQuery = useGetModelsQuery({
enabled: isAuthenticated,
refetchOnMount: 'always',

View file

@ -85,6 +85,33 @@ test.describe('chat projects', () => {
await expect(page).toHaveURL((url) => !url.searchParams.has('projectId'));
});
test('drops the project scope when the scoped project is deleted', async ({ page }) => {
test.setTimeout(90000);
const name = uniqueName('E2E Project');
const projectId = await createProject(page, name);
await page.goto(`/c/new?projectId=${projectId}`, { timeout: 10000 });
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeVisible();
// Delete the scoped project from the sidebar while it is selected on the landing.
const row = page.getByRole('button', { name, exact: true }).first();
await expect(row).toBeVisible();
const item = row.locator('..');
await item.hover();
await item.getByRole('button', { name: 'More options' }).click();
await page.getByRole('menuitem', { name: 'Delete' }).click();
await page.getByRole('dialog').getByRole('button', { name: 'Delete' }).click();
// The stale chip is gone, the URL drops the now-dead project scope, and the
// composer reverts to an unscoped chat (placeholder no longer names the project).
await expect(page.getByRole('button', { name: 'Remove from project' })).toBeHidden();
await expect(page).toHaveURL((url) => !url.searchParams.has('projectId'));
await expect(page.getByRole('textbox', { name: 'Message input' })).not.toHaveAttribute(
'placeholder',
new RegExp(name),
);
});
test('switches the project via the chip combobox', async ({ page }) => {
test.setTimeout(90000);
const nameA = uniqueName('E2E Project A');