mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 17:48:43 -10:00
fix(frontend): show plan guidance for memory chat paywalls (#16177)
### Why / What / How Starting a memory chat without the required plan currently shows a retry error and reports an expected HTTP 402 to Sentry. The memory page is reachable through settings, so this leaves the user without an upgrade path. A 402 from session creation now closes the current panel and shows the existing “The memory chat needs an active plan” toast. Its “See plans” action links to billing; installs with billing disabled retain the existing administrator guidance. The shared `ApiError` classifier uses the HTTP status, so authorization, rate-limit, service, network, and malformed-success failures keep the retry flow. The request generation also guards notifications, and unmounting invalidates pending creates. A late response from an old scope or a page the user has left cannot show a global plan toast or disturb the current chat. A stale request cannot release a newer request's create lock. ### Changes 🏗️ - Adopt the original memory chat paywall fix, preserving both original commits by `posthog-eu[bot]` and their Claude Code attribution. - Merge current `dev` normally and preserve its API, provider-routing, and shared paywall presentation contracts. - Guard obsolete responses before notification or panel updates, including when leaving memory settings. - Replace toast-spy-only assertions with page/component integrations using generated API hooks, the real request mutator, MSW responses, and the rendered shared toast and action. - Add deferred-response regressions for scope changes, unmounting, and overlapping session creation. ### Agents and large language models used - Original implementation: Claude Code with Claude Opus 5.5 (`claude-opus-5-5`), through PostHog Desktop. - Implementation takeover and validation: Codex with GPT-6 Astra (`gpt-6-astra`), through T3 Code. ### Checklist 📋 #### For code changes: - [x] I have clearly listed my changes in the PR description - [x] I have made a test plan - [x] I have tested my changes according to the test plan: - [x] Reproduce missing plan guidance with the current committed `dev` hook in the page/request integration test. - [x] Reproduce stale toasts after scope changes and page unmount on the original PR implementation merged with `dev`. - [x] Whole-frontend `corepack pnpm format`, `corepack pnpm lint`, and `corepack pnpm types`, in order. - [x] Memory page and request-mutator regression suites: 100 tests in 10 files pass, with at most two Vitest workers. Validation covers the component/request boundary with MSW and mock Next.js navigation. No browser, live-backend billing, or checkout journey was run. Whole-frontend lint passes with six existing hook-naming warnings in unchanged files. #### For configuration changes: - [x] `.env.default` is already compatible with my changes - [x] `docker-compose.yml` is already compatible with my changes - [x] I have included a list of my configuration changes in the PR description (under **Changes**) No configuration changes. <!-- posthog-self-driving-origin:01a172df-27bf-7892-bfe2-16d38808d104 --> ## Origin - Error tracking: [issue 1](https://eu.posthog.com/project/117338/error-tracking/01a172de-6809-70a3-9de9-99d27420dcad), [issue 2](https://eu.posthog.com/project/117338/error-tracking/01a177a9-a4f2-77e1-894f-5d96eb415323), [issue 3](https://eu.posthog.com/project/117338/error-tracking/01a180c8-939d-7522-b9c4-2efd5453e2d9) - First signal: 2026-10-25 - Inbox report: [open](https://eu.posthog.com/project/117338/inbox/reports/01a172df-27bf-7892-bfe2-16d38808d104?link_source=github_pr) - Likely cause: [`7b847b6`](https://github.com/Significant-Gravitas/AutoGPT/commit/7b847b6699ea7106c1da4422d2731d25a46e8ff8) - Task started by: auto-start, after the report was rated P3 and ready to fix <!-- /posthog-self-driving-origin:01a172df-27bf-7892-bfe2-16d38808d104 --> --------- Co-authored-by: posthog-eu[bot] <226701856+posthog-eu[bot]@users.noreply.github.com> Co-authored-by: Nicholas Tindle <[email protected]> Co-authored-by: GPT-6 Astra (Codex) <[email protected]>
This commit is contained in:
4 files changed
+482
-1
No files matched your search
+129
@@ -0,0 +1,129 @@
|
||||
import { screen, within } from "@/tests/integrations/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import {
|
||||
captureException,
|
||||
renderMemoryPage,
|
||||
setupMemoryChat,
|
||||
waitForSessionCreates,
|
||||
} from "./chat-test-helpers";
|
||||
|
||||
beforeEach(setupMemoryChat);
|
||||
|
||||
describe("Memory chat request lifetime", () => {
|
||||
it.each([402, 503])(
|
||||
"ignores a late HTTP %s from a previous scope",
|
||||
async (status) => {
|
||||
const response = Promise.withResolvers<Response>();
|
||||
server.use(
|
||||
http.post("*/api/chat/sessions", () => response.promise, {
|
||||
once: true,
|
||||
}),
|
||||
);
|
||||
renderMemoryPage();
|
||||
const user = userEvent.setup();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "View my summary" }),
|
||||
);
|
||||
await waitForSessionCreates(1);
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "Memory scope" }));
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", { name: /Growth Marketer/ }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "View Maria's summary" }),
|
||||
);
|
||||
expect(await screen.findByText("Your memory summary.")).toBeDefined();
|
||||
|
||||
response.resolve(
|
||||
HttpResponse.json({ detail: "Upgrade to continue." }, { status }),
|
||||
);
|
||||
await waitForSessionCreates(0);
|
||||
|
||||
const panel = screen.getByRole("complementary", {
|
||||
name: "Memory chat panel",
|
||||
});
|
||||
expect(within(panel).getByText("Maria's memory")).toBeDefined();
|
||||
expect(within(panel).getByText("Your memory summary.")).toBeDefined();
|
||||
expect(screen.queryByText("Could not start the memory chat")).toBeNull();
|
||||
expect(
|
||||
screen.queryByText("The memory chat needs an active plan"),
|
||||
).toBeNull();
|
||||
if (status === 402) expect(captureException).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
|
||||
it("ignores a late paywall response after leaving memory settings", async () => {
|
||||
const response = Promise.withResolvers<Response>();
|
||||
server.use(http.post("*/api/chat/sessions", () => response.promise));
|
||||
renderMemoryPage();
|
||||
const user = userEvent.setup();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "View my summary" }),
|
||||
);
|
||||
await waitForSessionCreates(1);
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "Leave memory settings" }),
|
||||
);
|
||||
|
||||
response.resolve(HttpResponse.json({}, { status: 402 }));
|
||||
await waitForSessionCreates(0);
|
||||
|
||||
expect(
|
||||
screen.queryByText("The memory chat needs an active plan"),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps a newer scope's create locked and handles its own paywall", async () => {
|
||||
const oldResponse = Promise.withResolvers<Response>();
|
||||
const newResponse = Promise.withResolvers<Response>();
|
||||
const bodies: unknown[] = [];
|
||||
server.use(
|
||||
http.post("*/api/chat/sessions", async ({ request }) => {
|
||||
bodies.push(await request.json());
|
||||
return bodies.length === 1 ? oldResponse.promise : newResponse.promise;
|
||||
}),
|
||||
);
|
||||
renderMemoryPage();
|
||||
const user = userEvent.setup();
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "View my summary" }),
|
||||
);
|
||||
await waitForSessionCreates(1);
|
||||
await user.click(screen.getByRole("button", { name: "Memory scope" }));
|
||||
await user.click(
|
||||
await screen.findByRole("menuitem", { name: /Growth Marketer/ }),
|
||||
);
|
||||
await user.click(
|
||||
await screen.findByRole("button", { name: "View Maria's summary" }),
|
||||
);
|
||||
await waitForSessionCreates(2);
|
||||
|
||||
oldResponse.resolve(HttpResponse.json({}, { status: 402 }));
|
||||
await waitForSessionCreates(1);
|
||||
expect(
|
||||
screen.queryByText("The memory chat needs an active plan"),
|
||||
).toBeNull();
|
||||
await user.click(
|
||||
screen.getByRole("button", { name: "View Maria's summary" }),
|
||||
);
|
||||
await waitForSessionCreates(1);
|
||||
expect(bodies).toEqual([null, { expert_id: "expert-maria" }]);
|
||||
|
||||
newResponse.resolve(HttpResponse.json({}, { status: 402 }));
|
||||
await waitForSessionCreates(0);
|
||||
expect(
|
||||
await screen.findByRole("link", { name: "See plans" }),
|
||||
).toBeDefined();
|
||||
expect(
|
||||
screen.queryByRole("complementary", { name: "Memory chat panel" }),
|
||||
).toBeNull();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
import { screen, waitFor } from "@/tests/integrations/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import {
|
||||
captureException,
|
||||
memoryChatTestConfig,
|
||||
renderMemoryPage,
|
||||
setupMemoryChat,
|
||||
} from "./chat-test-helpers";
|
||||
|
||||
beforeEach(setupMemoryChat);
|
||||
|
||||
async function openSummaryChat() {
|
||||
renderMemoryPage();
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(await screen.findByRole("button", { name: "View my summary" }));
|
||||
}
|
||||
|
||||
describe("Memory chat start", () => {
|
||||
it.each([
|
||||
[
|
||||
"a subscription error",
|
||||
() =>
|
||||
HttpResponse.json({ detail: "Upgrade to continue." }, { status: 402 }),
|
||||
],
|
||||
["a null body", () => HttpResponse.json(null, { status: 402 })],
|
||||
[
|
||||
"structured detail",
|
||||
() =>
|
||||
HttpResponse.json(
|
||||
{ detail: { message: "<script>unexpected()</script>" } },
|
||||
{ status: 402 },
|
||||
),
|
||||
],
|
||||
[
|
||||
"invalid JSON",
|
||||
() =>
|
||||
new HttpResponse("not json", {
|
||||
status: 402,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
}),
|
||||
],
|
||||
])(
|
||||
"offers plans for a 402 with %s without reporting an exception",
|
||||
async (_case, response) => {
|
||||
server.use(http.post("*/api/chat/sessions", response));
|
||||
|
||||
await openSummaryChat();
|
||||
|
||||
expect(
|
||||
await screen.findByText("The memory chat needs an active plan"),
|
||||
).toBeDefined();
|
||||
expect(screen.getByText("Choose one to continue.")).toBeDefined();
|
||||
const plans = screen.getByRole("link", { name: "See plans" });
|
||||
expect(plans.getAttribute("href")).toBe("/settings/billing");
|
||||
expect(
|
||||
screen.queryByRole("complementary", { name: "Memory chat panel" }),
|
||||
).toBeNull();
|
||||
expect(screen.queryByText("Could not start the memory chat")).toBeNull();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
|
||||
await userEvent.setup().click(plans);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText("The memory chat needs an active plan"),
|
||||
).toBeNull(),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
it("provides administrator guidance when billing is disabled", async () => {
|
||||
memoryChatTestConfig.billingEnabled = false;
|
||||
server.use(
|
||||
http.post("*/api/chat/sessions", () =>
|
||||
HttpResponse.json({}, { status: 402 }),
|
||||
),
|
||||
);
|
||||
|
||||
await openSummaryChat();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Ask whoever runs this server to set one up."),
|
||||
).toBeDefined();
|
||||
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("complementary", { name: "Memory chat panel" }),
|
||||
).toBeNull();
|
||||
expect(captureException).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([401, 403, 404, 409, 422, 429, 500, 503])(
|
||||
"keeps HTTP %s retryable even when its body mentions a paywall",
|
||||
async (status) => {
|
||||
server.use(
|
||||
http.post("*/api/chat/sessions", () =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
status: 402,
|
||||
detail: "A subscription is required. Upgrade to continue.",
|
||||
},
|
||||
{ status },
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
await openSummaryChat();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Could not start the memory chat"),
|
||||
).toBeDefined();
|
||||
expect(screen.getByRole("button", { name: "Retry" })).toBeDefined();
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status }),
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("The memory chat needs an active plan"),
|
||||
).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps network failures retryable without offering a plan", async () => {
|
||||
server.use(http.post("*/api/chat/sessions", () => HttpResponse.error()));
|
||||
|
||||
await openSummaryChat();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Could not start the memory chat"),
|
||||
).toBeDefined();
|
||||
expect(captureException).toHaveBeenCalledWith(expect.any(Error));
|
||||
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not mistake a malformed success body for a paywall", async () => {
|
||||
server.use(
|
||||
http.post("*/api/chat/sessions", () =>
|
||||
HttpResponse.json({ status: 402 }),
|
||||
),
|
||||
);
|
||||
|
||||
await openSummaryChat();
|
||||
|
||||
expect(
|
||||
await screen.findByText("Could not start the memory chat"),
|
||||
).toBeDefined();
|
||||
expect(captureException).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
message: "Failed to create memory chat session",
|
||||
}),
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
|
||||
});
|
||||
|
||||
it("retries a temporary subscription lookup failure and starts the chat", async () => {
|
||||
server.use(
|
||||
http.post(
|
||||
"*/api/chat/sessions",
|
||||
() =>
|
||||
HttpResponse.json(
|
||||
{
|
||||
detail:
|
||||
"Subscription state temporarily unavailable, retry shortly.",
|
||||
},
|
||||
{ status: 503 },
|
||||
),
|
||||
{ once: true },
|
||||
),
|
||||
);
|
||||
|
||||
await openSummaryChat();
|
||||
await userEvent
|
||||
.setup()
|
||||
.click(await screen.findByRole("button", { name: "Retry" }));
|
||||
|
||||
expect(await screen.findByText("Your memory summary.")).toBeDefined();
|
||||
expect(screen.queryByText("Could not start the memory chat")).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "See plans" })).toBeNull();
|
||||
});
|
||||
});
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
import { resetCopilotChatRegistry } from "@/app/(platform)/copilot/copilotChatRegistry";
|
||||
import { TEST_BACKEND_BASE_URL } from "@/app/(platform)/copilot/__tests__/sse-helpers";
|
||||
import {
|
||||
getGetV2GetSessionMockHandler200,
|
||||
getGetV2GetSessionResponseMock200,
|
||||
getPostV2CreateSessionMockHandler200,
|
||||
getPostV2CreateSessionResponseMock200,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat.msw";
|
||||
import {
|
||||
getListExpertsMockHandler200,
|
||||
getListExpertsResponseMock200,
|
||||
} from "@/app/api/__generated__/endpoints/experts/experts.msw";
|
||||
import {
|
||||
getGetMyExpertMemoryOverviewMockHandler200,
|
||||
getGetMyMemoryOverviewMockHandler200,
|
||||
getListMyExpertMemoryFactsMockHandler200,
|
||||
getListMyMemoryFactsMockHandler200,
|
||||
} from "@/app/api/__generated__/endpoints/memory/memory.msw";
|
||||
import { Toaster } from "@/components/molecules/Toast/toaster";
|
||||
import { server } from "@/mocks/mock-server";
|
||||
import {
|
||||
assistantTextChunks,
|
||||
streamSseResponse,
|
||||
} from "@/tests/integrations/copilot-sse";
|
||||
import { render, screen, waitFor } from "@/tests/integrations/test-utils";
|
||||
import { useIsMutating } from "@tanstack/react-query";
|
||||
import { http } from "msw";
|
||||
import { useState } from "react";
|
||||
import { expect, vi } from "vitest";
|
||||
import SettingsMemoryPage from "../page";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
captureException: vi.fn(),
|
||||
config: { billingEnabled: true },
|
||||
}));
|
||||
export const captureException = mocks.captureException;
|
||||
export const memoryChatTestConfig = mocks.config;
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureMessage: vi.fn(),
|
||||
captureException: mocks.captureException,
|
||||
addBreadcrumb: vi.fn(),
|
||||
getTraceData: () => ({}),
|
||||
}));
|
||||
|
||||
vi.mock("@/services/environment", async (importActual) => {
|
||||
const actual = await importActual<typeof import("@/services/environment")>();
|
||||
return {
|
||||
...actual,
|
||||
environment: {
|
||||
...actual.environment,
|
||||
isCloud: () => false,
|
||||
getAGPTServerBaseUrl: () => TEST_BACKEND_BASE_URL,
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/app/(platform)/copilot/helpers", async (importActual) => {
|
||||
const actual =
|
||||
await importActual<typeof import("@/app/(platform)/copilot/helpers")>();
|
||||
return {
|
||||
...actual,
|
||||
getCopilotAuthHeaders: async () => ({ "x-test-auth": "yes" }),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("@/services/feature-flags/use-get-flag", async (importActual) => {
|
||||
const actual =
|
||||
await importActual<
|
||||
typeof import("@/services/feature-flags/use-get-flag")
|
||||
>();
|
||||
return {
|
||||
...actual,
|
||||
useGetFlag: (flag: string) =>
|
||||
flag === actual.Flag.ENABLE_PLATFORM_PAYMENT &&
|
||||
mocks.config.billingEnabled,
|
||||
};
|
||||
});
|
||||
|
||||
function MemoryPageHarness() {
|
||||
const [visible, setVisible] = useState(true);
|
||||
const pending = useIsMutating({ mutationKey: ["postV2CreateSession"] });
|
||||
return (
|
||||
<>
|
||||
{visible ? <SettingsMemoryPage /> : null}
|
||||
<Toaster />
|
||||
<span data-testid="pending-session-requests">{pending}</span>
|
||||
<button onClick={() => setVisible(false)}>Leave memory settings</button>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function renderMemoryPage() {
|
||||
return render(<MemoryPageHarness />);
|
||||
}
|
||||
|
||||
export async function waitForSessionCreates(count: number) {
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId("pending-session-requests").textContent).toBe(
|
||||
String(count),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function setupMemoryChat() {
|
||||
resetCopilotChatRegistry();
|
||||
captureException.mockClear();
|
||||
memoryChatTestConfig.billingEnabled = true;
|
||||
window.history.replaceState({}, "", "/settings/memory");
|
||||
const sessionID = "memory-paywall-test-session";
|
||||
server.use(
|
||||
getListExpertsMockHandler200([
|
||||
{
|
||||
...getListExpertsResponseMock200()[0],
|
||||
id: "expert-maria",
|
||||
name: "Maria",
|
||||
role: "Growth Marketer",
|
||||
avatar_url: null,
|
||||
is_archived: false,
|
||||
},
|
||||
]),
|
||||
getListMyMemoryFactsMockHandler200({ expert_id: null, items: [] }),
|
||||
getGetMyMemoryOverviewMockHandler200({
|
||||
expert_id: null,
|
||||
facts: 0,
|
||||
entities: 0,
|
||||
episodes: 0,
|
||||
}),
|
||||
getListMyExpertMemoryFactsMockHandler200({
|
||||
expert_id: "expert-maria",
|
||||
items: [],
|
||||
}),
|
||||
getGetMyExpertMemoryOverviewMockHandler200({
|
||||
expert_id: "expert-maria",
|
||||
facts: 0,
|
||||
entities: 0,
|
||||
episodes: 0,
|
||||
}),
|
||||
getPostV2CreateSessionMockHandler200(
|
||||
getPostV2CreateSessionResponseMock200({ id: sessionID }),
|
||||
),
|
||||
getGetV2GetSessionMockHandler200(
|
||||
getGetV2GetSessionResponseMock200({
|
||||
id: sessionID,
|
||||
messages: [],
|
||||
active_stream: null,
|
||||
}),
|
||||
),
|
||||
http.post(
|
||||
`${TEST_BACKEND_BASE_URL}/api/chat/sessions/${sessionID}/stream`,
|
||||
({ request }) =>
|
||||
streamSseResponse(assistantTextChunks("Your memory summary."), {
|
||||
abortSignal: request.signal,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
+13
-1
@@ -6,6 +6,7 @@ import {
|
||||
useGetV2GetSession,
|
||||
usePostV2CreateSession,
|
||||
} from "@/app/api/__generated__/endpoints/chat/chat";
|
||||
import { toastIfPlanRequired } from "@/components/contextual/PaywallToast/helpers";
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
import type { UIDataTypes, UIMessage, UITools } from "ai";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
@@ -95,6 +96,12 @@ export function useMemoryChatPanel({ scopeExpertID }: Args) {
|
||||
// discarded instead of binding its session under the new scope's name.
|
||||
const scopeRef = useRef(scopeExpertID);
|
||||
const scopeGenRef = useRef(0);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
scopeGenRef.current += 1;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (scopeRef.current === scopeExpertID) return;
|
||||
scopeRef.current = scopeExpertID;
|
||||
@@ -127,8 +134,13 @@ export function useMemoryChatPanel({ scopeExpertID }: Args) {
|
||||
pendingPromptRef.current = MEMORY_CHAT_PROMPTS[lastSeedRef.current];
|
||||
setSessionId(response.data.id);
|
||||
} catch (err) {
|
||||
if (generation !== scopeGenRef.current) return;
|
||||
if (toastIfPlanRequired(err, "The memory chat needs an active plan")) {
|
||||
setIsOpen(false);
|
||||
return;
|
||||
}
|
||||
Sentry.captureException(err);
|
||||
if (generation === scopeGenRef.current) setStartError(true);
|
||||
setStartError(true);
|
||||
} finally {
|
||||
// Only release the lock if this call still owns it — a superseded
|
||||
// create must not unlock a newer scope's in-flight create.
|
||||
|
||||
Reference in new issue
Block a user