mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-08-27 04:07:05 +00:00
👻 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:
parent
40ec77e061
commit
28e937a422
3 changed files with 74 additions and 3 deletions
|
|
@ -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]);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue