get rid of useeffect

This commit is contained in:
Jun-te Kim 2026-04-27 16:00:33 +00:00
parent c0954e50d8
commit a7eef6db85
4 changed files with 136 additions and 92 deletions

View file

@ -1,6 +1,6 @@
"use client";
import { useEffect, useState, useRef } from "react";
import { useState, useSyncExternalStore } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
@ -26,10 +26,126 @@ interface Props {
isDomnaUser: boolean;
}
interface Snapshot {
data: TaskData | null;
uploadStatus: UploadStatus | null;
fetchError: boolean;
finalizeError: string | null;
}
const TERMINAL_STATUSES = new Set(["complete", "completed", "failed", "failure", "error"]);
const FAILED_STATUSES = new Set(["failed", "failure", "error"]);
const FINAL_UPLOAD_STATUSES = new Set(["complete", "needs_review"]);
function createProgressStore(args: {
taskId: string;
portfolioId: string;
uploadId: string;
onComplete: () => void;
}) {
let snapshot: Snapshot = {
data: null,
uploadStatus: null,
fetchError: false,
finalizeError: null,
};
const listeners = new Set<() => void>();
let intervalId: ReturnType<typeof setInterval> | null = null;
let combineFired = false;
let finalizeFired = false;
let refreshed = false;
function emit(patch: Partial<Snapshot>) {
snapshot = { ...snapshot, ...patch };
listeners.forEach((l) => l());
}
async function fireFinalize() {
try {
const res = await fetch(
`/api/portfolio/${args.portfolioId}/bulk-uploads/${args.uploadId}/finalize`,
{ method: "POST" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg =
body?.detail || body?.error || `Finalize failed (${res.status})`;
emit({ finalizeError: msg });
finalizeFired = false;
}
} catch (err) {
console.error("Failed to trigger finalize:", err);
emit({ finalizeError: err instanceof Error ? err.message : "Network error" });
finalizeFired = false;
}
}
async function poll() {
try {
const res = await fetch(`/api/tasks/${args.taskId}/summary`);
if (!res.ok) { emit({ fetchError: true }); return; }
const json: TaskData = await res.json();
emit({ data: json });
const status = json.status.toLowerCase();
if (TERMINAL_STATUSES.has(status)) {
if (!FAILED_STATUSES.has(status) && !combineFired) {
combineFired = true;
fetch(`/api/portfolio/${args.portfolioId}/bulk-uploads/${args.uploadId}/combine`, {
method: "POST",
}).catch((err) => console.error("Failed to trigger combiner:", err));
}
const uploadRes = await fetch(
`/api/portfolio/${args.portfolioId}/bulk-uploads/${args.uploadId}`
);
if (uploadRes.ok) {
const upload: UploadStatus = await uploadRes.json();
emit({ uploadStatus: upload });
if (upload.status === "awaiting_review" && !finalizeFired) {
finalizeFired = true;
fireFinalize();
}
if (FINAL_UPLOAD_STATUSES.has(upload.status) && !refreshed) {
refreshed = true;
if (intervalId) clearInterval(intervalId);
intervalId = null;
args.onComplete();
return;
}
}
}
} catch {
emit({ fetchError: true });
}
}
return {
subscribe(listener: () => void) {
listeners.add(listener);
if (listeners.size === 1 && intervalId === null && !refreshed) {
poll();
intervalId = setInterval(poll, 3000);
}
return () => {
listeners.delete(listener);
if (listeners.size === 0 && intervalId !== null) {
clearInterval(intervalId);
intervalId = null;
}
};
},
getSnapshot: () => snapshot,
retryFinalize() {
emit({ finalizeError: null });
finalizeFired = true;
fireFinalize();
},
};
}
export default function OnboardingProgress({
taskId,
portfolioSlug,
@ -38,88 +154,16 @@ export default function OnboardingProgress({
isDomnaUser,
}: Props) {
const router = useRouter();
const [data, setData] = useState<TaskData | null>(null);
const [uploadStatus, setUploadStatus] = useState<UploadStatus | null>(null);
const [fetchError, setFetchError] = useState(false);
const [finalizeError, setFinalizeError] = useState<string | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const combineFiredRef = useRef(false);
const finalizeFiredRef = useRef(false);
const refreshedRef = useRef(false);
async function fireFinalize() {
try {
const res = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/finalize`,
{ method: "POST" }
);
if (!res.ok) {
const body = await res.json().catch(() => ({}));
const msg =
body?.detail || body?.error || `Finalize failed (${res.status})`;
setFinalizeError(msg);
finalizeFiredRef.current = false;
}
} catch (err) {
console.error("Failed to trigger finalize:", err);
setFinalizeError(err instanceof Error ? err.message : "Network error");
finalizeFiredRef.current = false;
}
}
function retryFinalize() {
setFinalizeError(null);
finalizeFiredRef.current = true;
fireFinalize();
}
useEffect(() => {
async function poll() {
try {
const res = await fetch(`/api/tasks/${taskId}/summary`);
if (!res.ok) { setFetchError(true); return; }
const json: TaskData = await res.json();
setData(json);
const status = json.status.toLowerCase();
if (TERMINAL_STATUSES.has(status)) {
if (!FAILED_STATUSES.has(status) && !combineFiredRef.current) {
combineFiredRef.current = true;
fetch(`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/combine`, {
method: "POST",
}).catch((err) => console.error("Failed to trigger combiner:", err));
}
const uploadRes = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}`
);
if (uploadRes.ok) {
const upload: UploadStatus = await uploadRes.json();
setUploadStatus(upload);
if (upload.status === "awaiting_review" && !finalizeFiredRef.current) {
finalizeFiredRef.current = true;
fireFinalize();
}
if (FINAL_UPLOAD_STATUSES.has(upload.status) && !refreshedRef.current) {
refreshedRef.current = true;
if (intervalRef.current) clearInterval(intervalRef.current);
router.refresh();
return;
}
}
}
} catch {
setFetchError(true);
}
}
poll();
intervalRef.current = setInterval(poll, 3000);
return () => { if (intervalRef.current) clearInterval(intervalRef.current); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [taskId, portfolioId, portfolioSlug, uploadId, router]);
const [store] = useState(() =>
createProgressStore({
taskId,
portfolioId,
uploadId,
onComplete: () => router.refresh(),
})
);
const snap = useSyncExternalStore(store.subscribe, store.getSnapshot, store.getSnapshot);
const { data, uploadStatus, fetchError, finalizeError } = snap;
if (fetchError) return null;
if (!data) {
@ -191,7 +235,7 @@ export default function OnboardingProgress({
</div>
<button
type="button"
onClick={retryFinalize}
onClick={() => store.retryFinalize()}
className="shrink-0 px-2 py-1 rounded-md bg-red-600 text-white font-semibold hover:bg-red-700 transition-colors"
>
Retry

View file

@ -10,7 +10,7 @@ interface Props {
filename: string;
}
export default function StartOnboardingButton({ portfolioId, uploadId, filename }: Props) {
export default function StartAddressMatchingButton({ portfolioId, uploadId, filename }: Props) {
const router = useRouter();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -39,8 +39,8 @@ export default function StartOnboardingButton({ portfolioId, uploadId, filename
const { taskId, subTaskId } = await taskRes.json();
const onboardRes = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/onboard`,
const matchRes = await fetch(
`/api/portfolio/${portfolioId}/bulk-uploads/${uploadId}/start-address-matching`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
@ -48,9 +48,9 @@ export default function StartOnboardingButton({ portfolioId, uploadId, filename
}
);
if (!onboardRes.ok) {
const data = await onboardRes.json().catch(() => ({}));
throw new Error(data.error ?? "Failed to start onboarding");
if (!matchRes.ok) {
const data = await matchRes.json().catch(() => ({}));
throw new Error(data.error ?? "Failed to start address matching");
}
router.refresh();

View file

@ -15,7 +15,7 @@ import {
ExclamationCircleIcon,
ArrowPathIcon,
} from "@heroicons/react/24/outline";
import StartOnboardingButton from "./StartOnboardingButton";
import StartAddressMatchingButton from "./StartAddressMatchingButton";
import OnboardingProgress from "./OnboardingProgress";
function formatDate(date: Date) {
@ -166,7 +166,7 @@ export default async function BulkUploadDetailPage(props: {
Edit column mapping
<ArrowRightIcon className="h-3.5 w-3.5" />
</Link>
<StartOnboardingButton
<StartAddressMatchingButton
portfolioId={upload.portfolioId}
uploadId={uploadId}
filename={upload.filename}