♻️(frontend) generalize duplicating poller to transient items

The same polling loop fits any transient upload state.

Rename the duplicating poller to a transient one driven by
TRANSIENT_UPLOAD_STATES so converting items are picked up without copying
the hook, and the explorer refreshes automatically when background
conversion finishes.
This commit is contained in:
Nicolas Clerc
2026-06-03 16:47:38 +02:00
parent aeb6c9b5c7
commit 4f85801abe
4 changed files with 93 additions and 58 deletions
+1
View File
@@ -13,6 +13,7 @@ and this project adheres to
- ✨(backend) manage reconciliation requests for user accounts
- ✨(backend) add recursive folder export as ZIP archive
- ✨(frontend) add folder export action
- ✨(backend) background conversion of legacy Office files
### Changed
@@ -45,7 +45,7 @@ import {
} from "../../types/columns";
import { ColumnHeader } from "./headers/ColumnHeader";
import { CustomizableColumnHeader } from "./headers/CustomizableColumnHeader";
import { useDuplicatingItemsPoller } from "../../hooks/useDuplicatingItemsPoller";
import { useTransientItemsPoller } from "../../hooks/useTransientItemsPoller";
import { EmbeddedExplorerGridRow } from "./EmbeddedExplorerGridRow";
import posthog from "posthog-js";
@@ -128,7 +128,7 @@ export const EmbeddedExplorerGrid = (props: EmbeddedExplorerGridProps) => {
});
const contextMenu = useContextMenuContext();
useDuplicatingItemsPoller(props.items ?? EMPTY_ARRAY);
useTransientItemsPoller(props.items ?? EMPTY_ARRAY);
const selectionStore = useSelectionStore();
// TODO: This hook makes use of the ExplorerContext to manage the overred items. So, this component is not really standalone as it should be.
@@ -1,56 +0,0 @@
import { useMemo, useEffect, useRef } from "react";
import { useQueries } from "@tanstack/react-query";
import { getDriver } from "@/features/config/Config";
import { Item, ItemUploadState } from "@/features/drivers/types";
import { useRefreshItemCache } from "./useRefreshItems";
const POLL_INTERVAL = 3000;
const POLL_TIMEOUT = 10 * 60 * 1000;
export const useDuplicatingItemsPoller = (items: Item[]) => {
const refreshItemCache = useRefreshItemCache();
const startTimesRef = useRef<Map<string, number>>(new Map());
const duplicatingItems = useMemo(
() => items.filter((i) => i.upload_state === ItemUploadState.DUPLICATING),
[items],
);
useEffect(() => {
for (const item of duplicatingItems) {
if (!startTimesRef.current.has(item.id)) {
startTimesRef.current.set(item.id, Date.now());
}
}
const duplicatingIds = new Set(duplicatingItems.map((i) => i.id));
for (const id of startTimesRef.current.keys()) {
if (!duplicatingIds.has(id)) {
startTimesRef.current.delete(id);
}
}
}, [duplicatingItems]);
useQueries({
queries: duplicatingItems.map((item) => ({
queryKey: ["items", item.id, "duplicate-poll"],
queryFn: async () => {
const updatedItem = await getDriver().getItem(item.id);
if (updatedItem.upload_state !== ItemUploadState.DUPLICATING) {
await refreshItemCache(item.id, updatedItem);
}
return updatedItem;
},
refetchInterval: (query: { state: { data: Item | undefined } }) => {
const data = query.state.data;
if (data && data.upload_state !== ItemUploadState.DUPLICATING) {
return false;
}
const startTime = startTimesRef.current.get(item.id) ?? Date.now();
if (Date.now() - startTime > POLL_TIMEOUT) {
return false;
}
return POLL_INTERVAL;
},
})),
});
};
@@ -0,0 +1,90 @@
import { useMemo, useEffect, useRef } from "react";
import { useQueries, useQueryClient } from "@tanstack/react-query";
import { APIError } from "@/features/api/APIError";
import { getDriver } from "@/features/config/Config";
import { Item, TRANSIENT_UPLOAD_STATES } from "@/features/drivers/types";
import { useRefreshItemCache } from "./useRefreshItems";
import { useRemoveItemsFromPaginatedList } from "./useOptimisticPagination";
import {
addToast,
ToasterItem,
} from "@/features/ui/components/toaster/Toaster";
import { useTranslation } from "react-i18next";
const POLL_INTERVAL = 3000;
const POLL_TIMEOUT = 10 * 60 * 1000;
export const useTransientItemsPoller = (items: Item[]) => {
const refreshItemCache = useRefreshItemCache();
const removeItems = useRemoveItemsFromPaginatedList();
const queryClient = useQueryClient();
const { t } = useTranslation();
const startTimesRef = useRef<Map<string, number>>(new Map());
const failedToastShownRef = useRef<Set<string>>(new Set());
const transientItems = useMemo(
() =>
items.filter((i) => TRANSIENT_UPLOAD_STATES.includes(i.upload_state)),
[items],
);
useEffect(() => {
for (const item of transientItems) {
if (!startTimesRef.current.has(item.id)) {
startTimesRef.current.set(item.id, Date.now());
}
}
const transientIds = new Set(transientItems.map((i) => i.id));
for (const id of startTimesRef.current.keys()) {
if (!transientIds.has(id)) {
startTimesRef.current.delete(id);
}
}
}, [transientItems]);
useQueries({
queries: transientItems.map((item) => ({
queryKey: ["items", item.id, "transient-poll"],
queryFn: async (): Promise<Item | null> => {
try {
const updatedItem = await getDriver().getItem(item.id);
if (!TRANSIENT_UPLOAD_STATES.includes(updatedItem.upload_state)) {
await refreshItemCache(item.id, updatedItem);
}
return updatedItem;
} catch (error) {
if (error instanceof APIError && error.code === 404) {
removeItems(["items"], [item.id]);
queryClient.removeQueries({ queryKey: ["items", item.id] });
if (!failedToastShownRef.current.has(item.id)) {
failedToastShownRef.current.add(item.id);
addToast(
<ToasterItem type="error">
{t("explorer.actions.convert.modal.error")}
</ToasterItem>
);
}
return null;
}
throw error;
}
},
refetchInterval: (query: {
state: { data: Item | null | undefined };
}) => {
const data = query.state.data;
if (data === null) {
return false;
}
if (data && !TRANSIENT_UPLOAD_STATES.includes(data.upload_state)) {
return false;
}
const startTime = startTimesRef.current.get(item.id) ?? Date.now();
if (Date.now() - startTime > POLL_TIMEOUT) {
return false;
}
return POLL_INTERVAL;
},
})),
});
};