mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 21:02:34 +01:00
fix(ui): retain startup autosave through session recovery (#18412)
Reviewed and verified at a8783f8ace.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
c2971badde
commit
f2db909393
6 files changed
+282
-77
No files matched your search
@@ -178,10 +178,19 @@ test("replaying an accepted comment does not reopen completed work", async ({ pa
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("startup work retries a failed session lookup while other APIs stay healthy", async ({ page, request }) => {
|
for (const delayWorkspacePreference of [false, true]) test(delayWorkspacePreference
|
||||||
|
? "startup title survives session recovery while workspace preferences remount the editor"
|
||||||
|
: "startup work retries a failed session lookup while other APIs stay healthy", async ({ page, request }) => {
|
||||||
const company = await json(await request.post("/api/companies", { data: { name: `Session recovery ${Date.now()}` } }));
|
const company = await json(await request.post("/api/companies", { data: { name: `Session recovery ${Date.now()}` } }));
|
||||||
const session = await json(await request.get("/api/auth/get-session"));
|
const session = await json(await request.get("/api/auth/get-session"));
|
||||||
let healthy = false, saving = false, failedAfterSave = 0;
|
let healthy = false, saving = false, failedAfterSave = 0;
|
||||||
|
let releasePreference!: () => void;
|
||||||
|
const preferenceGate = new Promise<void>(resolve => { releasePreference = resolve; });
|
||||||
|
let sawPreference!: () => void;
|
||||||
|
const preferenceRequested = new Promise<void>(resolve => { sawPreference = resolve; });
|
||||||
|
if (delayWorkspacePreference) await page.route("**/api/workspace-preferences/me", async route => {
|
||||||
|
sawPreference(); await preferenceGate; await route.continue();
|
||||||
|
});
|
||||||
try {
|
try {
|
||||||
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
|
const issue = await json(await request.post(`/api/companies/${company.id}/issues`, {
|
||||||
data: { title: "Before session recovery", status: "backlog" },
|
data: { title: "Before session recovery", status: "backlog" },
|
||||||
@@ -199,12 +208,24 @@ test("startup work retries a failed session lookup while other APIs stay healthy
|
|||||||
saving = true;
|
saving = true;
|
||||||
await header.locator("textarea").press("Enter");
|
await header.locator("textarea").press("Enter");
|
||||||
await expect.poll(() => failedAfterSave).toBeGreaterThan(0);
|
await expect.poll(() => failedAfterSave).toBeGreaterThan(0);
|
||||||
|
if (delayWorkspacePreference) {
|
||||||
|
await expect(page.getByText("Couldn't update the task", { exact: true })).toBeVisible();
|
||||||
|
await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => resolve(null))));
|
||||||
|
}
|
||||||
healthy = true;
|
healthy = true;
|
||||||
|
if (delayWorkspacePreference) {
|
||||||
|
// Returning to the tab retries session lookup while the saved edit waits.
|
||||||
|
await page.evaluate(() => window.dispatchEvent(new Event("visibilitychange")));
|
||||||
|
await preferenceRequested;
|
||||||
|
await expect(page.getByTestId("task-chat-history-content")).toBeHidden();
|
||||||
|
releasePreference();
|
||||||
|
}
|
||||||
await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}`))).title,
|
await expect.poll(async () => (await json(await request.get(`/api/issues/${issue.id}`))).title,
|
||||||
{ timeout: 15_000 }).toBe("Saved after session recovery");
|
{ timeout: 15_000 }).toBe("Saved after session recovery");
|
||||||
await page.reload();
|
await page.reload();
|
||||||
await expect(header.getByRole("heading", { name: "Saved after session recovery", exact: true })).toBeVisible();
|
await expect(header.getByRole("heading", { name: "Saved after session recovery", exact: true })).toBeVisible();
|
||||||
} finally {
|
} finally {
|
||||||
|
releasePreference();
|
||||||
await request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } });
|
await request.patch(`/api/companies/${company.id}`, { data: { status: "archived" } });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -5,68 +5,12 @@ import {
|
|||||||
QueryClientContext,
|
QueryClientContext,
|
||||||
type DefaultError,
|
type DefaultError,
|
||||||
type MutateOptions,
|
type MutateOptions,
|
||||||
type QueryClient,
|
|
||||||
type UseMutationOptions,
|
type UseMutationOptions,
|
||||||
type UseMutationResult,
|
type UseMutationResult,
|
||||||
type MutationFunctionContext,
|
type MutationFunctionContext,
|
||||||
} from "@tanstack/react-query";
|
} from "@tanstack/react-query";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
import { PendingSendAccountChangedError } from "@/lib/pending-send";
|
||||||
import { authApi } from "@/api/auth";
|
import { bindAccount } from "@/lib/account-scope";
|
||||||
import { classifySendFailure, PendingSendAccountChangedError } from "@/lib/pending-send";
|
|
||||||
|
|
||||||
type AccountGeneration = { owner: string | undefined; generation: number };
|
|
||||||
const accounts = new WeakMap<QueryClient, AccountGeneration>();
|
|
||||||
|
|
||||||
function currentOwner(client: QueryClient): string | undefined {
|
|
||||||
const session = client.getQueryData<{ user?: { id?: string }; session?: { userId?: string } } | null>(queryKeys.auth.session);
|
|
||||||
return session === undefined ? undefined : session?.user?.id ?? session?.session?.userId ?? "signed-out";
|
|
||||||
}
|
|
||||||
|
|
||||||
function bindAccount(client: QueryClient) {
|
|
||||||
let account = accounts.get(client);
|
|
||||||
if (!account) {
|
|
||||||
account = { owner: currentOwner(client), generation: 0 };
|
|
||||||
accounts.set(client, account);
|
|
||||||
const tracked = account;
|
|
||||||
client.getQueryCache().subscribe(event => {
|
|
||||||
const owner = currentOwner(client);
|
|
||||||
const removed = event.type === "removed" && JSON.stringify(event.query.queryKey) === JSON.stringify(queryKeys.auth.session);
|
|
||||||
if (owner !== tracked.owner || removed) {
|
|
||||||
if (removed || tracked.owner !== undefined || tracked.generation !== 0) tracked.generation += 1;
|
|
||||||
tracked.owner = owner;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const { owner, generation } = account;
|
|
||||||
const isCurrentScope = () => account.generation === generation && (owner === undefined ? generation === 0 : account.owner === owner);
|
|
||||||
const isCurrentAccount = () => account.owner !== undefined && isCurrentScope();
|
|
||||||
const whenAccountReady = async () => {
|
|
||||||
if (account.owner !== undefined || !isCurrentScope()) return;
|
|
||||||
let stopped = false;
|
|
||||||
let unsubscribe = () => {};
|
|
||||||
const ready = new Promise<void>(resolve => {
|
|
||||||
unsubscribe = client.getQueryCache().subscribe(() => {
|
|
||||||
if (account.owner !== undefined || !isCurrentScope()) resolve();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
const lookup = async () => {
|
|
||||||
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
||||||
if (stopped || account.owner !== undefined || !isCurrentScope()) return;
|
|
||||||
try {
|
|
||||||
await client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false });
|
|
||||||
return;
|
|
||||||
} catch (error) {
|
|
||||||
if (stopped || account.owner !== undefined || !isCurrentScope()) return;
|
|
||||||
if (attempt === 2 || classifySendFailure(error) !== "pending") throw error;
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
try { await Promise.race([ready, lookup()]); }
|
|
||||||
finally { stopped = true; unsubscribe(); }
|
|
||||||
};
|
|
||||||
return { isCurrentAccount, isCurrentScope, whenAccountReady };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCurrentAccountGuard(): () => boolean {
|
export function useCurrentAccountGuard(): () => boolean {
|
||||||
const client = useContext(QueryClientContext);
|
const client = useContext(QueryClientContext);
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ import { act } from "react";
|
|||||||
import { createRoot, type Root } from "react-dom/client";
|
import { createRoot, type Root } from "react-dom/client";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { ApiError } from "@/api/client";
|
import { ApiError } from "@/api/client";
|
||||||
|
import { authApi } from "@/api/auth";
|
||||||
|
import { useSignOut } from "./useSignOut";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
import {
|
import {
|
||||||
@@ -120,6 +122,32 @@ describe("useDurableAutosave", () => {
|
|||||||
expect(store.getSnapshot().pendingWrites).toBe(0);
|
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each(["same-target", "different-target"])("preserves newer completed saves across first identity resolution (%s)", async target => {
|
||||||
|
let server = "Original";
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } });
|
||||||
|
const oldSave = vi.fn().mockRejectedValueOnce(outage()).mockImplementation(async value => { server = value; });
|
||||||
|
const show = (sourceId: string, targetKey: string, save: (value: string) => Promise<unknown>) => act(() => {
|
||||||
|
root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}>
|
||||||
|
<Harness sourceId={sourceId} targetKey={targetKey} save={save} />
|
||||||
|
</ConnectivityProvider></QueryClientProvider>);
|
||||||
|
});
|
||||||
|
show("old-startup-editor", "task-a:title", oldSave);
|
||||||
|
await act(async () => { expect(await current.run("Older title")).toEqual({ kind: "waiting" }); });
|
||||||
|
act(() => { root.unmount(); root = createRoot(container); });
|
||||||
|
act(() => client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } }));
|
||||||
|
const newSave = vi.fn(async value => { if (target === "same-target") server = value; });
|
||||||
|
show("new-known-editor", target === "same-target" ? "task-a:title" : "task-b:title", newSave);
|
||||||
|
await act(async () => { expect(await current.run("Newer title")).toEqual({ kind: "saved" }); });
|
||||||
|
expect(newSave).toHaveBeenCalledExactlyOnceWith("Newer title");
|
||||||
|
// The newer save has finished and released its registry entry before the
|
||||||
|
// detached startup job gets its first chance to adopt the account.
|
||||||
|
await advance(30_000);
|
||||||
|
expect(server).toBe(target === "same-target" ? "Newer title" : "Older title");
|
||||||
|
expect(oldSave).toHaveBeenCalledTimes(target === "same-target" ? 1 : 2);
|
||||||
|
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||||
|
client.clear();
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps a detached edit when a reopened editor resets only its local state and storage is unavailable", async () => {
|
it("keeps a detached edit when a reopened editor resets only its local state and storage is unavailable", async () => {
|
||||||
const storage = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
|
const storage = vi.spyOn(Storage.prototype, "setItem").mockImplementation(() => { throw new Error("Storage unavailable"); });
|
||||||
try {
|
try {
|
||||||
@@ -173,6 +201,118 @@ describe("useDurableAutosave", () => {
|
|||||||
client.clear();
|
client.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it.each([false, true])("keeps an unowned startup edit when identity and unmount race (identityFirst=%s)", async identityFirst => {
|
||||||
|
store.setBrowserOnline(false);
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } });
|
||||||
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
|
act(() => root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}>
|
||||||
|
<Harness sourceId="startup-remount" targetKey="task-a:title" save={save} />
|
||||||
|
</ConnectivityProvider></QueryClientProvider>));
|
||||||
|
await act(async () => { await current.run("Retained startup title"); });
|
||||||
|
expect(current.status).toBe("waiting");
|
||||||
|
act(() => {
|
||||||
|
if (identityFirst) client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||||
|
root.unmount(); root = createRoot(container);
|
||||||
|
if (!identityFirst) client.setQueryData(queryKeys.auth.session, { user: { id: "user-1" } });
|
||||||
|
});
|
||||||
|
await act(async () => { store.setBrowserOnline(true); store.reportSuccess(); });
|
||||||
|
await advance(2_000);
|
||||||
|
expect(save).toHaveBeenCalledExactlyOnceWith("Retained startup title");
|
||||||
|
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||||
|
client.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["different-account", "round-trip", "removed-session", "signed-out"])(
|
||||||
|
"does not adopt a later identity for a detached startup edit (%s)", async transition => {
|
||||||
|
store.setBrowserOnline(false);
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } });
|
||||||
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
|
act(() => root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}>
|
||||||
|
<Harness sourceId="private-startup" targetKey="private-title" save={save} />
|
||||||
|
</ConnectivityProvider></QueryClientProvider>));
|
||||||
|
await act(async () => { await current.run("Private startup title"); });
|
||||||
|
act(() => { root.unmount(); root = createRoot(container); });
|
||||||
|
act(() => {
|
||||||
|
if (transition === "removed-session") client.removeQueries({ queryKey: queryKeys.auth.session });
|
||||||
|
else client.setQueryData(queryKeys.auth.session, transition === "signed-out" ? null : { user: { id: "user-1" } });
|
||||||
|
if (transition === "round-trip") client.setQueryData(queryKeys.auth.session, { user: { id: "user-2" } });
|
||||||
|
client.setQueryData(queryKeys.auth.session, { user: { id: transition === "different-account" ? "user-2" : "user-1" } });
|
||||||
|
});
|
||||||
|
await act(async () => { store.setBrowserOnline(true); store.reportSuccess(); });
|
||||||
|
await advance(2_000);
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||||
|
client.clear();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("invalidates an unknown-account detached draft through the real sign-out reset", async () => {
|
||||||
|
store.setBrowserOnline(false);
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } });
|
||||||
|
client.setQueryData(queryKeys.health, { status: "ok", deploymentMode: "authenticated" });
|
||||||
|
const lookup = vi.spyOn(authApi, "getSession").mockReturnValue(new Promise(() => {}));
|
||||||
|
const signOutApi = vi.spyOn(authApi, "signOut").mockResolvedValue({ success: true });
|
||||||
|
const save = vi.fn().mockResolvedValue(undefined);
|
||||||
|
let signOut!: ReturnType<typeof useSignOut>;
|
||||||
|
function SignOut() { signOut = useSignOut(); return null; }
|
||||||
|
try {
|
||||||
|
act(() => root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}>
|
||||||
|
<Harness sourceId="unknown-account-draft" save={save} />
|
||||||
|
</ConnectivityProvider></QueryClientProvider>));
|
||||||
|
await act(async () => { await current.run("Private title before sign-out"); });
|
||||||
|
act(() => { root.unmount(); root = createRoot(container); });
|
||||||
|
act(() => root.render(<QueryClientProvider client={client}><SignOut /></QueryClientProvider>));
|
||||||
|
await act(async () => { await signOut.mutateAsync(); });
|
||||||
|
expect(signOutApi).toHaveBeenCalledOnce();
|
||||||
|
expect(client.getQueryData(queryKeys.auth.session)).toBeUndefined();
|
||||||
|
act(() => client.setQueryData(queryKeys.auth.session, { user: { id: "next-account" } }));
|
||||||
|
await act(async () => { store.setBrowserOnline(true); store.reportSuccess(); });
|
||||||
|
await advance(2_000);
|
||||||
|
expect(save).not.toHaveBeenCalled();
|
||||||
|
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||||
|
} finally { client.clear(); lookup.mockRestore(); signOutApi.mockRestore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([403, 404])("retains detached startup work after a definitive session lookup error (%s)", async status => {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } });
|
||||||
|
const lookup = vi.spyOn(authApi, "getSession").mockRejectedValue(new ApiError("Session lookup unavailable", status, {}));
|
||||||
|
const save = vi.fn().mockRejectedValueOnce(outage()).mockResolvedValue(undefined);
|
||||||
|
try {
|
||||||
|
act(() => root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}>
|
||||||
|
<Harness sourceId="startup-lookup-error" save={save} />
|
||||||
|
</ConnectivityProvider></QueryClientProvider>));
|
||||||
|
await act(async () => { await current.run("Retained title"); });
|
||||||
|
act(() => { root.unmount(); root = createRoot(container); });
|
||||||
|
await advance(0);
|
||||||
|
expect(lookup).toHaveBeenCalled();
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.getSnapshot().pendingWrites).toBe(1);
|
||||||
|
act(() => client.setQueryData(queryKeys.auth.session, { user: { id: "first-account" } }));
|
||||||
|
await advance(30_000);
|
||||||
|
expect(save).toHaveBeenCalledTimes(2);
|
||||||
|
expect(save).toHaveBeenLastCalledWith("Retained title");
|
||||||
|
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||||
|
} finally { client.clear(); lookup.mockRestore(); }
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds detached session discovery when the first identity never becomes available", async () => {
|
||||||
|
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false, enabled: false } } });
|
||||||
|
const lookup = vi.spyOn(authApi, "getSession").mockRejectedValue(new ApiError("Session unavailable", 403, {}));
|
||||||
|
const save = vi.fn().mockRejectedValue(outage());
|
||||||
|
try {
|
||||||
|
act(() => root.render(<QueryClientProvider client={client}><ConnectivityProvider store={store}>
|
||||||
|
<Harness sourceId="bounded-startup-lookup" save={save} />
|
||||||
|
</ConnectivityProvider></QueryClientProvider>));
|
||||||
|
await act(async () => { await current.run("Retained title"); });
|
||||||
|
act(() => { root.unmount(); root = createRoot(container); });
|
||||||
|
for (let attempt = 0; attempt < 10; attempt++) await advance(30_000);
|
||||||
|
expect(lookup.mock.calls.length).toBeGreaterThan(1);
|
||||||
|
expect(lookup.mock.calls.length).toBeLessThanOrEqual(9);
|
||||||
|
expect(save).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.getSnapshot().pendingWrites).toBe(0);
|
||||||
|
} finally { client.clear(); lookup.mockRestore(); }
|
||||||
|
});
|
||||||
|
|
||||||
it("bounds retries after an editor unmounts", async () => {
|
it("bounds retries after an editor unmounts", async () => {
|
||||||
const save = vi.fn().mockRejectedValue(outage());
|
const save = vi.fn().mockRejectedValue(outage());
|
||||||
render({ save });
|
render({ save });
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import { useCallback, useEffect, useId, useRef, useState } from "react";
|
import { useCallback, useContext, useEffect, useId, useRef, useState } from "react";
|
||||||
|
import { QueryClientContext } from "@tanstack/react-query";
|
||||||
|
import { bindAccount } from "@/lib/account-scope";
|
||||||
import { classifyError, describeError, type ErrorDescription } from "@/api/errors";
|
import { classifyError, describeError, type ErrorDescription } from "@/api/errors";
|
||||||
import { loadStructuredDraft, saveStructuredDraft } from "@/lib/composer-draft";
|
import { loadStructuredDraft, saveStructuredDraft } from "@/lib/composer-draft";
|
||||||
import { useConnectivityStore } from "@/lib/connectivity";
|
import { useConnectivityStore } from "@/lib/connectivity";
|
||||||
@@ -108,6 +110,7 @@ const SAVED_LINGER_MS = 1600;
|
|||||||
* - Errors become readable copy for "Couldn't save (reason)".
|
* - Errors become readable copy for "Couldn't save (reason)".
|
||||||
*/
|
*/
|
||||||
export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): DurableAutosave<T> {
|
export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): DurableAutosave<T> {
|
||||||
|
const client = useContext(QueryClientContext);
|
||||||
const owner = useDraftOwner();
|
const owner = useDraftOwner();
|
||||||
const readOwner = useDraftOwnerReader();
|
const readOwner = useDraftOwnerReader();
|
||||||
const instanceId = useId();
|
const instanceId = useId();
|
||||||
@@ -120,7 +123,7 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
|
|||||||
const targetKey = options.targetKey ?? options.sourceId;
|
const targetKey = options.targetKey ?? options.sourceId;
|
||||||
const targetRef = useRef(targetKey);
|
const targetRef = useRef(targetKey);
|
||||||
targetRef.current = targetKey;
|
targetRef.current = targetKey;
|
||||||
type Job = { token: symbol; registryKey: string; resends: number; saving?: boolean; id: number; target: string; owner: string | undefined; draft: T;
|
type Job = { token: symbol; registryKey: string; resends: number; saving?: boolean; id: number; target: string; owner: string | undefined; account: ReturnType<typeof bindAccount> | null; draft: T;
|
||||||
save: (draft: T) => Promise<unknown>; matchesServer?: DurableAutosaveOptions<T>["matchesServer"];
|
save: (draft: T) => Promise<unknown>; matchesServer?: DurableAutosaveOptions<T>["matchesServer"];
|
||||||
onRetryFailed?: DurableAutosaveJob<T>["onRetryFailed"]; storageKey: string | null; action: string };
|
onRetryFailed?: DurableAutosaveJob<T>["onRetryFailed"]; storageKey: string | null; action: string };
|
||||||
const jobs = useRef(new Map<string, Job>());
|
const jobs = useRef(new Map<string, Job>());
|
||||||
@@ -142,6 +145,22 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
|
|||||||
entry?.active.delete(job.token);
|
entry?.active.delete(job.token);
|
||||||
if (entry && !entry.active.size) registry.delete(job.registryKey);
|
if (entry && !entry.active.size) registry.delete(job.registryKey);
|
||||||
};
|
};
|
||||||
|
// Resolve first identity from the same generation guard used by mutations.
|
||||||
|
// This also runs after unmount, when React cannot migrate the job for us.
|
||||||
|
function owned(job: Job) {
|
||||||
|
if (job.account && !job.account.isCurrentScope()) return false;
|
||||||
|
const current = readOwner();
|
||||||
|
if (job.owner === undefined && current !== undefined) {
|
||||||
|
const nextKey = JSON.stringify([current, job.target]);
|
||||||
|
if (registry.get(job.registryKey)?.latest !== job.token || registry.has(nextKey)) return false;
|
||||||
|
releaseJob(job);
|
||||||
|
job.owner = current;
|
||||||
|
job.registryKey = nextKey;
|
||||||
|
registry.set(nextKey, { latest: job.token, active: new Set([job.token]) });
|
||||||
|
}
|
||||||
|
return current === job.owner && generations.current.get(job.target) === job.id
|
||||||
|
&& registry.get(job.registryKey)?.latest === job.token;
|
||||||
|
}
|
||||||
const mounted = useRef(true);
|
const mounted = useRef(true);
|
||||||
const savingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const savingTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const savedTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
@@ -160,17 +179,11 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
|
|||||||
if (previous === undefined && owner !== undefined) {
|
if (previous === undefined && owner !== undefined) {
|
||||||
for (const job of jobs.current.values()) {
|
for (const job of jobs.current.values()) {
|
||||||
if (job.owner !== undefined) continue;
|
if (job.owner !== undefined) continue;
|
||||||
const nextKey = JSON.stringify([owner, job.target]);
|
if (!owned(job)) {
|
||||||
const superseded = registry.get(job.registryKey)?.latest !== job.token || registry.has(nextKey);
|
releaseJob(job);
|
||||||
releaseJob(job);
|
|
||||||
if (superseded) {
|
|
||||||
jobs.current.delete(job.target);
|
jobs.current.delete(job.target);
|
||||||
if (generations.current.get(job.target) === job.id) generations.current.delete(job.target);
|
if (generations.current.get(job.target) === job.id) generations.current.delete(job.target);
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
job.owner = owner;
|
|
||||||
job.registryKey = nextKey;
|
|
||||||
registry.set(nextKey, { latest: job.token, active: new Set([job.token]) });
|
|
||||||
}
|
}
|
||||||
refresh(value => value + 1);
|
refresh(value => value + 1);
|
||||||
return;
|
return;
|
||||||
@@ -194,12 +207,12 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
|
|||||||
return stored?.version === 1 && stored.draft !== undefined ? stored.draft : null;
|
return stored?.version === 1 && stored.draft !== undefined ? stored.draft : null;
|
||||||
}, [storageKey]);
|
}, [storageKey]);
|
||||||
const execute = useCallback(async (job: Job, retry = false): Promise<DurableAutosaveResult> => {
|
const execute = useCallback(async (job: Job, retry = false): Promise<DurableAutosaveResult> => {
|
||||||
const owned = () => readOwner() === job.owner && generations.current.get(job.target) === job.id && registry.get(job.registryKey)?.latest === job.token;
|
const stillOwned = () => owned(job);
|
||||||
const visible = () => mounted.current && owned() && targetRef.current === job.target;
|
const visible = () => mounted.current && stillOwned() && targetRef.current === job.target;
|
||||||
const publish = (status: DurableAutosaveStatus, error: ErrorDescription | null = null) => {
|
const publish = (status: DurableAutosaveStatus, error: ErrorDescription | null = null) => {
|
||||||
if (visible()) setView({ target: job.target, status, error });
|
if (visible()) setView({ target: job.target, status, error });
|
||||||
};
|
};
|
||||||
if (!owned()) {
|
if (!stillOwned()) {
|
||||||
if (jobs.current.get(job.target) === job) jobs.current.delete(job.target);
|
if (jobs.current.get(job.target) === job) jobs.current.delete(job.target);
|
||||||
releaseJob(job);
|
releaseJob(job);
|
||||||
if (mounted.current) refresh(value => value + 1);
|
if (mounted.current) refresh(value => value + 1);
|
||||||
@@ -234,7 +247,20 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
|
|||||||
remove();
|
remove();
|
||||||
return { kind: "saved" };
|
return { kind: "saved" };
|
||||||
};
|
};
|
||||||
try { await job.save(job.draft); return done(); }
|
try {
|
||||||
|
if (!mounted.current && job.account && !job.account.isCurrentAccount()) {
|
||||||
|
try { await job.account.whenAccountReady(); }
|
||||||
|
catch {
|
||||||
|
// Session discovery has not attempted the write. Keep its draft within
|
||||||
|
// the existing retry budget unless an account transition invalidated it.
|
||||||
|
if (!stillOwned()) remove();
|
||||||
|
return { kind: "waiting" };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!stillOwned()) { remove(); return { kind: "waiting" }; }
|
||||||
|
await job.save(job.draft);
|
||||||
|
return done();
|
||||||
|
}
|
||||||
catch (initialCause) {
|
catch (initialCause) {
|
||||||
let cause = initialCause;
|
let cause = initialCause;
|
||||||
if (classifyError(cause) === "conflict" && job.matchesServer) {
|
if (classifyError(cause) === "conflict" && job.matchesServer) {
|
||||||
@@ -255,7 +281,10 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
|
|||||||
const current = optionsRef.current;
|
const current = optionsRef.current;
|
||||||
const target = current.targetKey ?? current.sourceId;
|
const target = current.targetKey ?? current.sourceId;
|
||||||
const admittedOwner = ownerRef.current ?? readOwner();
|
const admittedOwner = ownerRef.current ?? readOwner();
|
||||||
const job: Job = { token: Symbol(), registryKey: JSON.stringify([admittedOwner, target]), resends: 0, id: ++sequence.current, target, owner: admittedOwner, draft,
|
// A newer known-account edit supersedes an older startup job even if this
|
||||||
|
// save finishes before that job can adopt the account's registry key.
|
||||||
|
if (admittedOwner !== undefined) registry.delete(JSON.stringify([undefined, target]));
|
||||||
|
const job: Job = { token: Symbol(), registryKey: JSON.stringify([admittedOwner, target]), resends: 0, id: ++sequence.current, target, owner: admittedOwner, account: client ? bindAccount(client) : null, draft,
|
||||||
save: overrides.save ?? current.save, matchesServer: overrides.matchesServer ?? current.matchesServer,
|
save: overrides.save ?? current.save, matchesServer: overrides.matchesServer ?? current.matchesServer,
|
||||||
onRetryFailed: overrides.onRetryFailed, storageKey: current.storageKey ?? null, action: current.action ?? "save" };
|
onRetryFailed: overrides.onRetryFailed, storageKey: current.storageKey ?? null, action: current.action ?? "save" };
|
||||||
const previous = jobs.current.get(target);
|
const previous = jobs.current.get(target);
|
||||||
@@ -266,7 +295,7 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
|
|||||||
generations.current.set(target, job.id); jobs.current.set(target, job);
|
generations.current.set(target, job.id); jobs.current.set(target, job);
|
||||||
refresh(value => value + 1);
|
refresh(value => value + 1);
|
||||||
return execute(job);
|
return execute(job);
|
||||||
}, [execute, readOwner]);
|
}, [client, execute, readOwner]);
|
||||||
const waitingJobs = [...jobs.current.values()].filter(job => !job.saving).length;
|
const waitingJobs = [...jobs.current.values()].filter(job => !job.saving).length;
|
||||||
const auto = useAutoResend({ sourceId: options.sourceId, active: waitingJobs > 0, pendingCount: waitingJobs,
|
const auto = useAutoResend({ sourceId: options.sourceId, active: waitingJobs > 0, pendingCount: waitingJobs,
|
||||||
resend: async (): Promise<SendOutcome> => {
|
resend: async (): Promise<SendOutcome> => {
|
||||||
@@ -281,7 +310,6 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
|
|||||||
detachedWork.current = () => {
|
detachedWork.current = () => {
|
||||||
if (!jobs.current.size) return;
|
if (!jobs.current.size) return;
|
||||||
if (auto.stalled) { for (const job of jobs.current.values()) releaseJob(job); jobs.current.clear(); return; }
|
if (auto.stalled) { for (const job of jobs.current.values()) releaseJob(job); jobs.current.clear(); return; }
|
||||||
const admittedOwner = ownerRef.current;
|
|
||||||
const source = `${optionsRef.current.sourceId}:detached:${instanceId}`;
|
const source = `${optionsRef.current.sourceId}:detached:${instanceId}`;
|
||||||
let timer: ReturnType<typeof setTimeout> | null = null;
|
let timer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let disposed = false, running = false;
|
let disposed = false, running = false;
|
||||||
@@ -298,7 +326,11 @@ export function useDurableAutosave<T>(options: DurableAutosaveOptions<T>): Durab
|
|||||||
};
|
};
|
||||||
const wake = async () => {
|
const wake = async () => {
|
||||||
if (disposed || running) return;
|
if (disposed || running) return;
|
||||||
if (mounted.current || readOwner() !== admittedOwner || admittedOwner === undefined) { release(); return; }
|
if (mounted.current) { release(); return; }
|
||||||
|
for (const job of jobs.current.values()) if (!owned(job)) {
|
||||||
|
releaseJob(job);
|
||||||
|
jobs.current.delete(job.target);
|
||||||
|
}
|
||||||
if (!jobs.current.size) { release(); return; }
|
if (!jobs.current.size) { release(); return; }
|
||||||
store.setPendingWrites(source, jobs.current.size);
|
store.setPendingWrites(source, jobs.current.size);
|
||||||
if (store.getSnapshot().status !== "online") return;
|
if (store.getSnapshot().status !== "online") return;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|||||||
import { authApi } from "@/api/auth";
|
import { authApi } from "@/api/auth";
|
||||||
import { navigateTopLevel } from "@/lib/browserNavigation";
|
import { navigateTopLevel } from "@/lib/browserNavigation";
|
||||||
import { queryKeys } from "@/lib/queryKeys";
|
import { queryKeys } from "@/lib/queryKeys";
|
||||||
|
import { invalidateAccountScope } from "@/lib/account-scope";
|
||||||
import { useCloudInstance } from "./useCloudInstance";
|
import { useCloudInstance } from "./useCloudInstance";
|
||||||
|
|
||||||
const CLOUD_SIGN_OUT_PATH = "/cloud/logout";
|
const CLOUD_SIGN_OUT_PATH = "/cloud/logout";
|
||||||
@@ -47,6 +48,7 @@ export function useSignOut({ onSignedOut }: UseSignOutOptions = {}) {
|
|||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
if (cloud) {
|
if (cloud) {
|
||||||
|
invalidateAccountScope(queryClient);
|
||||||
onSignedOut?.();
|
onSignedOut?.();
|
||||||
navigateTopLevel(CLOUD_SIGN_OUT_PATH);
|
navigateTopLevel(CLOUD_SIGN_OUT_PATH);
|
||||||
return "cloud" as const;
|
return "cloud" as const;
|
||||||
@@ -58,6 +60,7 @@ export function useSignOut({ onSignedOut }: UseSignOutOptions = {}) {
|
|||||||
onSuccess: async (target) => {
|
onSuccess: async (target) => {
|
||||||
if (target === "cloud") return;
|
if (target === "cloud") return;
|
||||||
|
|
||||||
|
invalidateAccountScope(queryClient);
|
||||||
onSignedOut?.();
|
onSignedOut?.();
|
||||||
|
|
||||||
// Drop every account-scoped cache entry, rather than invalidating a
|
// Drop every account-scoped cache entry, rather than invalidating a
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import { queryKeys } from "./queryKeys";
|
||||||
|
import { authApi } from "@/api/auth";
|
||||||
|
import { classifySendFailure } from "./pending-send";
|
||||||
|
|
||||||
|
type AccountGeneration = { owner: string | undefined; generation: number };
|
||||||
|
const accounts = new WeakMap<QueryClient, AccountGeneration>();
|
||||||
|
|
||||||
|
function currentOwner(client: QueryClient): string | undefined {
|
||||||
|
const session = client.getQueryData<{ user?: { id?: string }; session?: { userId?: string } } | null>(queryKeys.auth.session);
|
||||||
|
return session === undefined ? undefined : session?.user?.id ?? session?.session?.userId ?? "signed-out";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A successful sign-out ends pending work even if the first session never loaded. */
|
||||||
|
export function invalidateAccountScope(client: QueryClient) {
|
||||||
|
const account = accounts.get(client);
|
||||||
|
if (account) account.generation += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bindAccount(client: QueryClient) {
|
||||||
|
let account = accounts.get(client);
|
||||||
|
if (!account) {
|
||||||
|
account = { owner: currentOwner(client), generation: 0 };
|
||||||
|
accounts.set(client, account);
|
||||||
|
const tracked = account;
|
||||||
|
client.getQueryCache().subscribe(event => {
|
||||||
|
const owner = currentOwner(client);
|
||||||
|
const removed = event.type === "removed" && JSON.stringify(event.query.queryKey) === JSON.stringify(queryKeys.auth.session);
|
||||||
|
if (owner !== tracked.owner || removed) {
|
||||||
|
if (removed || tracked.owner !== undefined || tracked.generation !== 0) tracked.generation += 1;
|
||||||
|
tracked.owner = owner;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const { owner, generation } = account;
|
||||||
|
const isCurrentScope = () => account.generation === generation && (owner === undefined ? generation === 0 : account.owner === owner);
|
||||||
|
const isCurrentAccount = () => account.owner !== undefined && isCurrentScope();
|
||||||
|
const whenAccountReady = async () => {
|
||||||
|
if (account.owner !== undefined || !isCurrentScope()) return;
|
||||||
|
let stopped = false;
|
||||||
|
let unsubscribe = () => {};
|
||||||
|
const ready = new Promise<void>(resolve => {
|
||||||
|
unsubscribe = client.getQueryCache().subscribe(() => {
|
||||||
|
if (account.owner !== undefined || !isCurrentScope()) resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const lookup = async () => {
|
||||||
|
for (let attempt = 0; attempt < 3; attempt += 1) {
|
||||||
|
if (stopped || account.owner !== undefined || !isCurrentScope()) return;
|
||||||
|
try {
|
||||||
|
await client.fetchQuery({ queryKey: queryKeys.auth.session, queryFn: () => authApi.getSession(), retry: false });
|
||||||
|
return;
|
||||||
|
} catch (error) {
|
||||||
|
if (stopped || account.owner !== undefined || !isCurrentScope()) return;
|
||||||
|
if (attempt === 2 || classifySendFailure(error) !== "pending") throw error;
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 250 * 2 ** attempt));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
try { await Promise.race([ready, lookup()]); }
|
||||||
|
finally { stopped = true; unsubscribe(); }
|
||||||
|
};
|
||||||
|
return { isCurrentAccount, isCurrentScope, whenAccountReady };
|
||||||
|
}
|
||||||
|
|
||||||
Reference in new issue
Block a user