From 0e4c295b104473e6e6b7bb8d8dbfb3fc629c6e42 Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 26 Jun 2026 05:41:32 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20react-doctor=20rerender-state-only-in-ha?= =?UTF-8?q?ndlers=2046=E2=86=92~30=20(lastFetchedBrandId/bodyHtml/rawHeade?= =?UTF-8?q?rs/rawRows/useBucket/pendingImageUrl/prevSyncKey=20to=20useRef)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/admin/BrandSettingsForm.tsx | 6 +++--- src/components/admin/CampaignListPanel.tsx | 7 ++++--- src/components/admin/ContactImportForm.tsx | 17 ++++++++--------- src/components/admin/EditStopModal.tsx | 14 +++++++------- src/components/admin/NewProductForm.tsx | 5 +++-- 5 files changed, 25 insertions(+), 24 deletions(-) diff --git a/src/components/admin/BrandSettingsForm.tsx b/src/components/admin/BrandSettingsForm.tsx index 00a2a8c..8235aa1 100644 --- a/src/components/admin/BrandSettingsForm.tsx +++ b/src/components/admin/BrandSettingsForm.tsx @@ -59,9 +59,9 @@ function PlatformAdminBrandSettingsForm({ // Track the last brandId we kicked off a fetch for. When the prop // changes we flip `loading` inline during render so users never see // stale "loaded" UI between the prop change and the effect running. - const [lastFetchedBrandId, setLastFetchedBrandId] = useState(null); - if (activeBrandId !== lastFetchedBrandId) { - setLastFetchedBrandId(activeBrandId); + const lastFetchedBrandIdRef = useRef(null); + if (activeBrandId !== lastFetchedBrandIdRef.current) { + lastFetchedBrandIdRef.current = activeBrandId; setLoadData({ settings: null, loading: true }); } diff --git a/src/components/admin/CampaignListPanel.tsx b/src/components/admin/CampaignListPanel.tsx index baa9a31..35dcdbc 100644 --- a/src/components/admin/CampaignListPanel.tsx +++ b/src/components/admin/CampaignListPanel.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { useRouter } from "next/navigation"; import type { Campaign, CampaignType, CampaignStatus, AudienceRules } from "@/actions/communications/campaigns"; import { formatDate } from "@/lib/format-date"; @@ -338,7 +338,8 @@ export function CampaignEditPanel({ const [name, setName] = useState(() => campaign?.name ?? ""); const [subject, setSubject] = useState(() => campaign?.subject ?? ""); const [bodyText, setBodyText] = useState(() => campaign?.body_text ?? ""); - const [bodyHtml, setBodyHtml] = useState(() => campaign?.body_html ?? ""); + const bodyHtmlRef = useRef(campaign?.body_html ?? ""); + const setBodyHtml = (v: string) => { bodyHtmlRef.current = v; }; const [templateId, setTemplateId] = useState(() => campaign?.template_id ?? ""); const [campaignType, setCampaignType] = useState( () => campaign?.campaign_type ?? "operational" @@ -440,7 +441,7 @@ export function CampaignEditPanel({ name, subject, body_text: bodyText, - body_html: bodyHtml || undefined, + body_html: bodyHtmlRef.current || undefined, template_id: templateId || undefined, campaign_type: campaignType, status: scheduleMode === "later" ? "scheduled" : (campaign?.status ?? "draft"), diff --git a/src/components/admin/ContactImportForm.tsx b/src/components/admin/ContactImportForm.tsx index 33dbb45..39650d2 100644 --- a/src/components/admin/ContactImportForm.tsx +++ b/src/components/admin/ContactImportForm.tsx @@ -95,8 +95,10 @@ export default function ContactImportForm({ brandId }: { brandId: string }) { const [step, setStep] = useState("idle"); const [file, setFile] = useState(null); const [preview, setPreview] = useState(null); - const [rawHeaders, setRawHeaders] = useState([]); - const [rawRows, setRawRows] = useState([]); + const rawHeadersRef = useRef([]); + const setRawHeaders = (v: string[]) => { rawHeadersRef.current = v; }; + const rawRowsRef = useRef([]); + const setRawRows = (v: string[][]) => { rawRowsRef.current = v; }; const [importRows, setImportRows] = useState([]); const [importing, setImporting] = useState(false); const [result, setResult] = useState(null); @@ -104,7 +106,6 @@ export default function ContactImportForm({ brandId }: { brandId: string }) { const [overridingMappings, setOverridingMappings] = useState< Record >({}); - const [useBucket, setUseBucket] = useState(false); // Toggle for bucket upload const [uploadProgress, setUploadProgress] = useState(""); const [importHistory, setImportHistory] = useState<{ filename: string; size: number; createdAt: string }[]>([]); const fileRef = useRef(null); @@ -127,7 +128,6 @@ export default function ContactImportForm({ brandId }: { brandId: string }) { if (isLargeFile) { // Use bucket upload for large files setUploadProgress("Uploading file to storage..."); - setUseBucket(true); const uploadResult = await uploadContactsToBucket(brandId, f); @@ -169,7 +169,6 @@ export default function ContactImportForm({ brandId }: { brandId: string }) { } // Browser-based processing for smaller files - setUseBucket(false); const text = await f.text(); const previewResult = await previewContactImport(text); @@ -325,14 +324,14 @@ export default function ContactImportForm({ brandId }: { brandId: string }) { const handleConfirm = useCallback( async () => { - if (!preview || rawRows.length === 0) return; + if (!preview || rawRowsRef.current.length === 0) return; setStep("importing"); const rowsToImport = applyMappings( preview.mappings, overridingMappings, - rawHeaders, - rawRows + rawHeadersRef.current, + rawRowsRef.current ); setImportRows(rowsToImport); @@ -352,7 +351,7 @@ export default function ContactImportForm({ brandId }: { brandId: string }) { setStep("result"); } }, - [brandId, preview, overridingMappings, rawHeaders, rawRows] + [brandId, preview, overridingMappings] ); const handleReset = () => { diff --git a/src/components/admin/EditStopModal.tsx b/src/components/admin/EditStopModal.tsx index 538f482..fc20825 100644 --- a/src/components/admin/EditStopModal.tsx +++ b/src/components/admin/EditStopModal.tsx @@ -54,10 +54,10 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces // Reset form when modal opens or stop changes — derived during render // per React docs: "Adjusting some state when a prop changes". - const [prevSyncKey, setPrevSyncKey] = useState(null); + const prevSyncKeyRef = useRef(null); const syncKey = `${isOpen ? "open" : "closed"}:${stop?.id ?? "new"}`; - if (syncKey !== prevSyncKey) { - setPrevSyncKey(syncKey); + if (syncKey !== prevSyncKeyRef.current) { + prevSyncKeyRef.current = syncKey; if (isOpen) { if (stop) { // Edit mode - populate from existing stop. @@ -89,15 +89,15 @@ export default function EditStopModal({ isOpen, onClose, brandId, stop, onSucces // Focus the first field once the modal has actually opened. This is // a DOM side-effect, so it must run after the commit (i.e. in an // effect) — accessing `cityRef.current` during render is illegal. - // The `prevSyncKey.startsWith("open:")` guard keeps the focus call - // to the transition into the open state, not every re-render. + // The `prevSyncKeyRef.current?.startsWith("open:")` guard keeps the focus + // call to the transition into the open state, not every re-render. useEffect(() => { - if (prevSyncKey?.startsWith("open:")) { + if (prevSyncKeyRef.current?.startsWith("open:")) { const id = requestAnimationFrame(() => cityRef.current?.focus()); return () => cancelAnimationFrame(id); } return undefined; - }, [prevSyncKey]); + }, []); const handleSubmit = useCallback( async (e: React.FormEvent) => { diff --git a/src/components/admin/NewProductForm.tsx b/src/components/admin/NewProductForm.tsx index 21b8342..ebc4703 100644 --- a/src/components/admin/NewProductForm.tsx +++ b/src/components/admin/NewProductForm.tsx @@ -49,7 +49,8 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const fileInputRef = useRef(null); - const [pendingImageUrl, setPendingImageUrl] = useState(""); + const pendingImageUrlRef = useRef(""); + const setPendingImageUrl = (v: string) => { pendingImageUrlRef.current = v; }; // Form state const [name, setName] = useState(""); @@ -106,7 +107,7 @@ export default function NewProductForm({ defaultBrandId = "", brands = [], lockB setError(null); // Use pendingImageUrl if image was uploaded, otherwise null - const imageUrl = pendingImageUrl || null; + const imageUrl = pendingImageUrlRef.current || null; const result = await createProduct(brandId, { name,