mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
Version recent-task snapshots separately from activity, reject stale cache updates, and publish only on query or membership changes. Migrate history into an isolated storage namespace while preserving restart retries. Add regression coverage for conflicting caches, migration, comments, unchanged writes, and storage implementations on Node 24 and Node 26. Co-Authored-By: Paperclip <noreply@paperclip.ing>
135 lines
5.2 KiB
TypeScript
135 lines
5.2 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
|
import { useQueries } from "@tanstack/react-query";
|
|
import type { Issue } from "@paperclipai/shared";
|
|
import { ApiError } from "@/api/client";
|
|
import { issuesApi } from "@/api/issues";
|
|
import { queryKeys } from "@/lib/queryKeys";
|
|
import {
|
|
RECENT_TASKS_UPDATED_EVENT,
|
|
getRecentTasksStorageKey,
|
|
mergeRecentTaskSnapshot,
|
|
migrateRecentTasks,
|
|
pruneRecentTasks,
|
|
readRecentTasks,
|
|
updateRecentTaskSnapshots,
|
|
type RecentTaskEntry,
|
|
} from "@/lib/recent-tasks";
|
|
|
|
type RecentTasksUpdatedDetail = {
|
|
storageKey: string;
|
|
entries: RecentTaskEntry[];
|
|
};
|
|
|
|
const RECENT_TASK_ORDER_DEBOUNCE_MS = 1_000;
|
|
|
|
export function useRecentTasks({
|
|
companyId,
|
|
userId,
|
|
}: {
|
|
companyId: string | null | undefined;
|
|
userId: string | null | undefined;
|
|
}) {
|
|
const storageKey = useMemo(
|
|
() => companyId ? getRecentTasksStorageKey(companyId, userId) : null,
|
|
[companyId, userId],
|
|
);
|
|
const [entries, setEntries] = useState<RecentTaskEntry[]>(() => (
|
|
storageKey && companyId ? readRecentTasks(storageKey, companyId) : []
|
|
));
|
|
|
|
useEffect(() => {
|
|
if (storageKey && companyId) migrateRecentTasks(storageKey, companyId);
|
|
setEntries(storageKey && companyId ? readRecentTasks(storageKey, companyId) : []);
|
|
}, [companyId, storageKey]);
|
|
|
|
useEffect(() => {
|
|
if (!storageKey || !companyId) return;
|
|
|
|
const sync = () => setEntries(readRecentTasks(storageKey, companyId));
|
|
const onStorage = (event: StorageEvent) => {
|
|
if (event.key === storageKey) sync();
|
|
};
|
|
const onRecentTasksUpdated = (event: Event) => {
|
|
const detail = (event as CustomEvent<RecentTasksUpdatedDetail>).detail;
|
|
if (detail?.storageKey === storageKey) setEntries(detail.entries);
|
|
};
|
|
|
|
window.addEventListener("storage", onStorage);
|
|
window.addEventListener(RECENT_TASKS_UPDATED_EVENT, onRecentTasksUpdated);
|
|
return () => {
|
|
window.removeEventListener("storage", onStorage);
|
|
window.removeEventListener(RECENT_TASKS_UPDATED_EVENT, onRecentTasksUpdated);
|
|
};
|
|
}, [companyId, storageKey]);
|
|
|
|
// Keep query observers in a fixed order when activity changes the display order.
|
|
const queryEntries = [...entries].sort((left, right) => left.id.localeCompare(right.id));
|
|
const membership = JSON.stringify(queryEntries.map((entry) => entry.id));
|
|
const detailQueries = useQueries({
|
|
queries: queryEntries.map((entry) => ({
|
|
queryKey: queryKeys.issues.detail(entry.id),
|
|
queryFn: () => issuesApi.get(entry.id),
|
|
retry: false,
|
|
staleTime: 30_000,
|
|
})),
|
|
});
|
|
|
|
const issueById = new Map(detailQueries.flatMap((query) => query.data ? [[query.data.id, query.data] as const] : []));
|
|
const refreshedEntries = entries.map((entry) => {
|
|
const issue = issueById.get(entry.id);
|
|
if (!issue || issue.companyId !== companyId || issue.hiddenAt) return entry;
|
|
return mergeRecentTaskSnapshot(entry, issue);
|
|
});
|
|
const queryRevision = detailQueries
|
|
.map((query) => `${query.dataUpdatedAt}:${query.errorUpdatedAt}:${query.status}`)
|
|
.join("|");
|
|
|
|
useEffect(() => {
|
|
if (!storageKey || !companyId || detailQueries.length === 0) return;
|
|
|
|
const resolvedIssues: Issue[] = [];
|
|
const removeIds = new Set<string>();
|
|
detailQueries.forEach((query, index) => {
|
|
const entry = queryEntries[index];
|
|
if (!entry) return;
|
|
if (query.data) {
|
|
if (query.data.companyId !== companyId || query.data.hiddenAt) {
|
|
removeIds.add(entry.id);
|
|
} else {
|
|
resolvedIssues.push(query.data);
|
|
}
|
|
} else if (query.error instanceof ApiError && [403, 404].includes(query.error.status)) {
|
|
removeIds.add(entry.id);
|
|
}
|
|
});
|
|
|
|
updateRecentTaskSnapshots(storageKey, companyId, resolvedIssues);
|
|
pruneRecentTasks(storageKey, companyId, removeIds);
|
|
// Publish only when queries or membership change. A storage notification must
|
|
// not feed the same cached query results back into storage in every tab.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [companyId, membership, queryRevision, storageKey]);
|
|
|
|
const [settledOrder, setSettledOrder] = useState(() => entries.map((entry) => entry.id));
|
|
const activityRevision = JSON.stringify(entries.map((entry) => [entry.id, entry.recordedAt]));
|
|
useEffect(() => {
|
|
const latestOrder = (JSON.parse(activityRevision) as Array<[string, number]>).map(([id]) => id);
|
|
// Additions and removals are immediate. Only activity-driven moves wait for quiet.
|
|
if (JSON.stringify([...settledOrder].sort((a, b) => a.localeCompare(b))) !== membership) {
|
|
setSettledOrder(latestOrder);
|
|
return;
|
|
}
|
|
if (latestOrder.every((id, index) => id === settledOrder[index])) return;
|
|
const timeout = window.setTimeout(() => setSettledOrder(latestOrder), RECENT_TASK_ORDER_DEBOUNCE_MS);
|
|
return () => window.clearTimeout(timeout);
|
|
}, [activityRevision, membership, settledOrder, storageKey]);
|
|
|
|
const entryById = new Map(refreshedEntries.map((entry) => [entry.id, entry]));
|
|
const hasSameMembership = settledOrder.length === entries.length && settledOrder.every((id) => entryById.has(id));
|
|
|
|
return {
|
|
entries: hasSameMembership ? settledOrder.map((id) => entryById.get(id)!) : refreshedEntries,
|
|
storageKey,
|
|
};
|
|
}
|