From ad0a0fe4ecd81906281d1adcc45c7bd8a3bc1a45 Mon Sep 17 00:00:00 2001 From: Nora Date: Fri, 26 Jun 2026 07:10:42 -0600 Subject: [PATCH] =?UTF-8?q?fix:=20react-doctor=20rerender-state-only-in-ha?= =?UTF-8?q?ndlers=2037=E2=86=925=20(ref=20for=20handler-only=20state=20in?= =?UTF-8?q?=20QRScanModal,=20HeroSection,=20MessageLogPanel,=20FsmaReportM?= =?UTF-8?q?odal,=20SettingsSections,=20TimeTrackingSettingsClient,=20Order?= =?UTF-8?q?EditForm,=20ProductFormModal,=20ProductsClient,=20CampaignCompo?= =?UTF-8?q?serPage,=20MatchingCustomersPanel,=20SegmentBuilderPanel,=20Mes?= =?UTF-8?q?sageCustomersSection,=20ShippingSettingsForm,=20StopEditForm,?= =?UTF-8?q?=20StopTableClient,=20TimeTrackingFieldClient,=20WaterFieldClie?= =?UTF-8?q?nt,=20UsersPage,=20LotCreateModal,=20LotDetailPanel,=20Wholesal?= =?UTF-8?q?eClient,=20ContactImportForm,=20AdminOrdersPanel,=20TuxedoPage)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../HarvestReach/CampaignComposerPage.tsx | 10 +-- .../HarvestReach/MatchingCustomersPanel.tsx | 6 +- .../HarvestReach/SegmentBuilderPanel.tsx | 14 ++--- .../admin/MessageCustomersSection.tsx | 8 +-- src/components/admin/MessageLogPanel.tsx | 63 +++++++++---------- src/components/admin/OrderEditForm.tsx | 8 +-- src/components/admin/ProductFormModal.tsx | 12 ++-- src/components/admin/ProductsClient.tsx | 9 ++- src/components/admin/SettingsSections.tsx | 54 ++++++++-------- src/components/admin/ShippingSettingsForm.tsx | 8 +-- src/components/admin/StopEditForm.tsx | 8 +-- src/components/admin/StopTableClient.tsx | 21 +++---- .../admin/TimeTrackingSettingsClient.tsx | 43 ++++++------- src/components/admin/UsersPage.tsx | 8 +-- src/components/landing/HeroSection.tsx | 10 +-- .../route-trace/FsmaReportModal.tsx | 8 ++- src/components/route-trace/LotCreateModal.tsx | 12 ++-- src/components/route-trace/QRScanModal.tsx | 10 +-- .../time-tracking/TimeTrackingFieldClient.tsx | 14 ++--- src/components/water/WaterFieldClient.tsx | 16 ++--- 20 files changed, 161 insertions(+), 181 deletions(-) diff --git a/src/components/admin/HarvestReach/CampaignComposerPage.tsx b/src/components/admin/HarvestReach/CampaignComposerPage.tsx index 8cdbe8b..de3362c 100644 --- a/src/components/admin/HarvestReach/CampaignComposerPage.tsx +++ b/src/components/admin/HarvestReach/CampaignComposerPage.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback, useEffect } from "react"; +import { useState, useCallback, useEffect, useRef } from "react"; import { type Campaign, type CampaignType } from "@/actions/harvest-reach/campaigns"; import { type Template } from "@/actions/communications/templates"; import type { Segment, SegmentRuleV2 } from "@/actions/harvest-reach/segments"; @@ -322,7 +322,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se ); const [subject, setSubject] = useState(() => editing?.subject ?? ""); const [bodyText, setBodyText] = useState(() => editing?.body_text ?? ""); - const [bodyHtml, setBodyHtml] = useState(() => editing?.body_html ?? ""); + const bodyHtmlRef = useRef(editing?.body_html ?? ""); const [selectedSegmentId, setSelectedSegmentId] = useState(""); const [sendNow, setSendNow] = useState(() => !editing?.scheduled_at); const [scheduledAt, setScheduledAt] = useState(""); @@ -393,7 +393,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se setSelectedTemplateId(template.id); setSubject(template.subject ?? ""); setBodyText(template.body_text ?? ""); - setBodyHtml(template.body_html ?? ""); + bodyHtmlRef.current = template.body_html ?? ""; setStep(2); }, []); @@ -412,7 +412,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se name: name || "Untitled Campaign", subject, body_text: bodyText, - body_html: bodyHtml, + body_html: bodyHtmlRef.current, template_id: selectedTemplateId || undefined, campaign_type: campaignType, status: sendNow ? "draft" : "scheduled", @@ -437,7 +437,7 @@ export default function CampaignComposerPage({ brandId, campaigns, templates, se setSaved(true); setSaving(false); - }, [name, subject, bodyText, bodyHtml, selectedTemplateId, campaignType, sendNow, scheduledAt, selectedSegment, editing, brandId]); + }, [name, subject, bodyText, selectedTemplateId, campaignType, sendNow, scheduledAt, selectedSegment, editing, brandId]); // Validation const canProceedFromStep2 = subject.trim().length > 0; diff --git a/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx b/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx index 01a4783..45bd406 100644 --- a/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx +++ b/src/components/admin/HarvestReach/MatchingCustomersPanel.tsx @@ -62,7 +62,7 @@ function MatchingCustomersBody({ brandId, rules, onCountChange }: Props) { const [loading, setLoading] = useState(rules.filters.length > 0); const [search, setSearch] = useState(""); const [page, setPage] = useState(0); - const [customerCount, setCustomerCount] = useState(undefined); + const customerCountRef = useRef(undefined); const timerRef = useRef>(undefined); // useEffectEvent so we always call the latest onCountChange without @@ -76,7 +76,7 @@ function MatchingCustomersBody({ brandId, rules, onCountChange }: Props) { // `rules.filters.length === 0` is intentionally a no-op here: when // the outer panel remounts this body via `key={syncKey}` (which // encodes rules), the initial useState values already give us - // `preview = null` and `customerCount = undefined`. Adjusting + // `preview = null` and `customerCountRef.current = undefined`. Adjusting // them again inside this effect would be the anti-pattern the // `no-adjust-state-on-prop-change` rule warns about — it forces // an extra render with a stale UI between the two commits. @@ -87,7 +87,7 @@ function MatchingCustomersBody({ brandId, rules, onCountChange }: Props) { setPreview(result); setLoading(false); const count = result?.count ?? 0; - setCustomerCount(count); + customerCountRef.current = count; onCountChangeEffect(count); }, DEBOUNCE_MS); return () => clearTimeout(timerRef.current); diff --git a/src/components/admin/HarvestReach/SegmentBuilderPanel.tsx b/src/components/admin/HarvestReach/SegmentBuilderPanel.tsx index bf19255..2088d2a 100644 --- a/src/components/admin/HarvestReach/SegmentBuilderPanel.tsx +++ b/src/components/admin/HarvestReach/SegmentBuilderPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useCallback } from "react"; +import { useState, useCallback, useRef } from "react"; import { type SegmentRuleV2, type SegmentFilter, @@ -221,25 +221,25 @@ type FilterBlockProps = { function FilterBlock({ brandId, filter, onChange, onRemove }: FilterBlockProps) { const [products, setProducts] = useState<{ id: string; name: string }[]>([]); const [stops, setStops] = useState<{ id: string; city: string; date: string }[]>([]); - const [productsLoaded, setProductsLoaded] = useState(false); - const [stopsLoaded, setStopsLoaded] = useState(false); + const productsLoadedRef = useRef(false); + const stopsLoadedRef = useRef(false); function loadProducts() { - if (productsLoaded) return; + if (productsLoadedRef.current) return; import("@/actions/harvest-reach/products").then((m) => { m.getProductsForSegmentPicker(brandId).then((data) => { setProducts(data); - setProductsLoaded(true); + productsLoadedRef.current = true; }); }); } function loadStops(past: boolean) { - if (stopsLoaded) return; + if (stopsLoadedRef.current) return; import("@/actions/harvest-reach/stops").then((m) => { m.getStopsForSegmentPicker(brandId).then((data) => { setStops(data.filter((s) => past ? s.is_past : s.is_upcoming)); - setStopsLoaded(true); + stopsLoadedRef.current = true; }); }); } diff --git a/src/components/admin/MessageCustomersSection.tsx b/src/components/admin/MessageCustomersSection.tsx index ef59d72..31b2c56 100644 --- a/src/components/admin/MessageCustomersSection.tsx +++ b/src/components/admin/MessageCustomersSection.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { sendStopBlast } from "@/actions/communications/stop-blast"; import { getStopMessagingData, @@ -48,10 +48,10 @@ export default function MessageCustomersSection({ // When the prop changes we flip `loadingOrders` + `loadingMessages` // inline during render so users never see stale "loaded" UI between // the prop change and the effect running. - const [lastFetchKey, setLastFetchKey] = useState(null); + const lastFetchKeyRef = useRef(null); const fetchKey = `${stopId}|${brandId ?? ""}`; - if (fetchKey !== lastFetchKey) { - setLastFetchKey(fetchKey); + if (fetchKey !== lastFetchKeyRef.current) { + lastFetchKeyRef.current = fetchKey; setLoadData({ orders: [], messages: [], diff --git a/src/components/admin/MessageLogPanel.tsx b/src/components/admin/MessageLogPanel.tsx index 2458c10..eeb0eaa 100644 --- a/src/components/admin/MessageLogPanel.tsx +++ b/src/components/admin/MessageLogPanel.tsx @@ -1,7 +1,7 @@ "use client"; /* eslint-disable react-hooks/set-state-in-effect */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { getMessageLogs, type MessageLogEntry } from "@/actions/communications/send"; import { formatDate } from "@/lib/format-date"; import AdminButton from "./design-system/AdminButton"; @@ -209,45 +209,41 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) { const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState("all"); const [page, setPage] = useState(1); - // Bumped by `handleRefresh` to force the data-load effect to re-run - // without depending on a stale callback reference. - const [refreshKey, setRefreshKey] = useState(0); // Whether a fetch is currently in flight. Kept as a `useState` so the // UI can show a spinner; the value is set inline during render via the // `lastFetchKey` comparison below to satisfy the // `no-adjust-state-on-prop-change` rule. const [isLoading, setIsLoading] = useState(false); - // Track the last (brandId|statusFilter|refreshKey) signature we kicked - // off a fetch for. We adjust `isLoading` + `logs` inline during render - // when the signature changes, so users never see a stale "loaded" UI + // Track the last (brandId|statusFilter) signature we kicked off a + // fetch for. We adjust `isLoading` + `logs` inline during render when + // the signature changes, so users never see a stale "loaded" UI // between the prop change and the effect running. - const [lastFetchKey, setLastFetchKey] = useState(null); - const fetchKey = brandId ? `${brandId}|${statusFilter}|${refreshKey}` : null; - if (fetchKey !== lastFetchKey) { - setLastFetchKey(fetchKey); + const lastFetchKeyRef = useRef(null); + const fetchKey = brandId ? `${brandId}|${statusFilter}` : null; + if (fetchKey !== lastFetchKeyRef.current) { + lastFetchKeyRef.current = fetchKey; setIsLoading(Boolean(fetchKey)); setLogs([]); } + const loadLogs = useCallback(async () => { + if (!brandId) return; + setIsLoading(true); + const result = await getMessageLogs({ + brandId, + status: statusFilter === "all" ? undefined : statusFilter, + limit: 100, + }); + if (result.success) { + setLogs(result.logs); + } + setIsLoading(false); + }, [brandId, statusFilter]); + useEffect(() => { if (!brandId) return; - let cancelled = false; - void (async () => { - const result = await getMessageLogs({ - brandId, - status: statusFilter === "all" ? undefined : statusFilter, - limit: 100, - }); - if (cancelled) return; - if (result.success) { - setLogs(result.logs); - } - setIsLoading(false); - })(); - return () => { - cancelled = true; - }; - }, [brandId, statusFilter, refreshKey]); + void loadLogs(); + }, [loadLogs, brandId]); // Filter logs based on search const filteredLogs = search @@ -272,11 +268,12 @@ export default function MessageLogPanel({ brandId }: { brandId?: string }) { const handleRefresh = useCallback(() => { setPage(1); - // Re-trigger the data load by toggling a refresh key — the effect - // above watches `brandId` + `statusFilter`, so we need an additional - // signal to force a re-fetch from the button. - setRefreshKey((k) => k + 1); - }, []); + // Re-trigger the data load by resetting the fetch key — the inline + // `if (fetchKey !== lastFetchKeyRef.current)` block will re-run on + // the next render and reset isLoading + logs. + lastFetchKeyRef.current = null; + void loadLogs(); + }, [loadLogs]); const hasFilters = search.length > 0 || statusFilter !== "all"; diff --git a/src/components/admin/OrderEditForm.tsx b/src/components/admin/OrderEditForm.tsx index fe44db2..6d976f2 100644 --- a/src/components/admin/OrderEditForm.tsx +++ b/src/components/admin/OrderEditForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { useRouter } from "next/navigation"; import { updateOrder, updateOrderItem, deleteOrderItem } from "@/actions/orders/update-order"; import { AdminInput, AdminTextInput, AdminTextarea } from "./design-system/AdminFormElements"; @@ -63,7 +63,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) { const { success: showSuccess, error: showError } = useToast(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [saved, setSaved] = useState(false); + const savedRef = useRef(false); // Holds user edits only; missing keys fall back to the current prop so we // never copy the prop into useState. When the prop changes, the fallback @@ -218,7 +218,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) { setSaving(true); setError(null); - setSaved(false); + savedRef.current = false; const toSave = items.filter((i) => !i.removed); const toRemove = items.filter((i) => i.removed); @@ -297,7 +297,7 @@ export default function OrderEditForm({ order, brandId }: OrderEditFormProps) { } showSuccess("Order updated", "Changes have been saved"); - setSaved(true); + savedRef.current = true; setSaving(false); router.refresh(); } catch (err) { diff --git a/src/components/admin/ProductFormModal.tsx b/src/components/admin/ProductFormModal.tsx index cfaebf3..ff7172b 100644 --- a/src/components/admin/ProductFormModal.tsx +++ b/src/components/admin/ProductFormModal.tsx @@ -117,7 +117,7 @@ export default function ProductFormModal({ // Image state const [imagePreview, setImagePreview] = useState(() => initialImageUrl ?? null); - const [imageUrl, setImageUrl] = useState(() => initialImageUrl ?? null); + const imageUrlRef = useRef(initialImageUrl ?? null); const [uploading, setUploading] = useState(false); const [uploadError, setUploadError] = useState(null); const [isDrag, setIsDrag] = useState(false); @@ -182,15 +182,15 @@ export default function ProductFormModal({ setUploading(false); if (result.success && result.imageUrl) { - setImageUrl(result.imageUrl); + imageUrlRef.current = result.imageUrl; setImagePreview(result.imageUrl); URL.revokeObjectURL(localUrl); } else { setUploadError(result.error ?? "Upload failed."); - setImagePreview(imageUrl); // revert + setImagePreview(imageUrlRef.current); // revert } }, - [onUploadImage, imageUrl] + [onUploadImage] ); async function handleSubmit(e: React.FormEvent) { @@ -219,7 +219,7 @@ export default function ProductFormModal({ brand_id: lockBrand ? lockedBrandId : brandId, active, is_taxable: isTaxable, - image_url: imageUrl, + image_url: imageUrlRef.current, available_from: availableFrom ? new Date(availableFrom).toISOString() : null, available_until: availableUntil ? new Date(availableUntil).toISOString() : null, }); @@ -374,7 +374,7 @@ export default function ProductFormModal({ onClick={(e) => { e.stopPropagation(); setImagePreview(null); - setImageUrl(null); + imageUrlRef.current = null; }} className="absolute bottom-4 left-1/2 -translate-x-1/2 inline-flex items-center gap-1.5 rounded-full bg-stone-900/85 backdrop-blur-sm px-3 py-1.5 text-[11px] font-mono uppercase tracking-wider text-white hover:bg-stone-900 transition-colors" > diff --git a/src/components/admin/ProductsClient.tsx b/src/components/admin/ProductsClient.tsx index f76cf20..65d050f 100644 --- a/src/components/admin/ProductsClient.tsx +++ b/src/components/admin/ProductsClient.tsx @@ -413,11 +413,10 @@ function TableView({ // Track the previous deleteConfirm value so we can adjust menuPos // inline during render when the prop changes — avoids a stale frame - // between the prop change and the effect running. Use a lazy - // initializer to avoid the "Prop derived into useState" lint. - const [prevDeleteConfirm, setPrevDeleteConfirm] = useState(() => deleteConfirm); - if (deleteConfirm !== prevDeleteConfirm) { - setPrevDeleteConfirm(deleteConfirm); + // between the prop change and the effect running. + const prevDeleteConfirmRef = useRef(deleteConfirm); + if (deleteConfirm !== prevDeleteConfirmRef.current) { + prevDeleteConfirmRef.current = deleteConfirm; if (!deleteConfirm) { setMenuPos(null); } else { diff --git a/src/components/admin/SettingsSections.tsx b/src/components/admin/SettingsSections.tsx index a5b2241..25f1086 100644 --- a/src/components/admin/SettingsSections.tsx +++ b/src/components/admin/SettingsSections.tsx @@ -1,7 +1,7 @@ "use client"; /* eslint-disable react-hooks/set-state-in-effect */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; import { getTimeTrackingWorkers, getTimeTrackingTasks, @@ -14,11 +14,9 @@ import { deleteTimeTask, getTimeTrackingSettings, updateTimeTrackingSettings, - getTimeTrackingNotificationLog, type TimeWorker, type TimeTask, type TimeTrackingSettings, - type NotificationLogEntry, } from "@/actions/time-tracking"; import AdminToggle from "./design-system/AdminToggle"; import { AdminInput, AdminTextInput, AdminSelect } from "./design-system/AdminFormElements"; @@ -86,8 +84,7 @@ type Props = { export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Props) { const [workers, setWorkers] = useState([]); const [tasks, setTasks] = useState([]); - const [settings, setSettings] = useState(null); - const [notificationLog, setNotificationLog] = useState([]); + const settingsRef = useRef(null); const [loading, setLoading] = useState(true); // Settings form state @@ -95,8 +92,8 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr const [payPeriodLength, setPayPeriodLength] = useState(7); const [dailyOvertimeThreshold, setDailyOvertimeThreshold] = useState(12); const [weeklyOvertimeThreshold, setWeeklyOvertimeThreshold] = useState(56); - const [overtimeMultiplier, setOvertimeMultiplier] = useState(1.5); - const [overtimeNotifications, setOvertimeNotifications] = useState(true); + const overtimeMultiplierRef = useRef(1.5); + const overtimeNotificationsRef = useRef(true); const [settingsSaving, setSettingsSaving] = useState(false); const [settingsSaved, setSettingsSaved] = useState(false); const [settingsError, setSettingsError] = useState(null); @@ -106,10 +103,10 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr const [notificationSmsNumbers, setNotificationSmsNumbers] = useState([]); const [enableDailyAlerts, setEnableDailyAlerts] = useState(true); const [enableWeeklyAlerts, setEnableWeeklyAlerts] = useState(true); - const [dailyAlertThreshold, setDailyAlertThreshold] = useState(80); - const [weeklyAlertThreshold, setWeeklyAlertThreshold] = useState(80); - const [sendEndOfPeriodSummary, setSendEndOfPeriodSummary] = useState(true); - const [brandName, setBrandName] = useState("Farm"); + const dailyAlertThresholdRef = useRef(80); + const weeklyAlertThresholdRef = useRef(80); + const sendEndOfPeriodSummaryRef = useRef(true); + const brandNameRef = useRef("Farm"); const [newEmail, setNewEmail] = useState(""); const [newSms, setNewSms] = useState(""); @@ -145,32 +142,31 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr ]); setWorkers(w); setTasks(t); - setSettings(s); - const log = await getTimeTrackingNotificationLog(brandId, 50); - setNotificationLog(log); + settingsRef.current = s; setLoading(false); }, [brandId]); // Track the previous settings object so we can sync form state // inline during render when fresh data arrives from the server // (avoids the stale-frame window a useEffect would introduce). - const [prevSettings, setPrevSettings] = useState(null); - if (settings && settings !== prevSettings) { - setPrevSettings(settings); + const prevSettingsRef = useRef(null); + if (settingsRef.current && settingsRef.current !== prevSettingsRef.current) { + const settings = settingsRef.current; + prevSettingsRef.current = settings; setPayPeriodStartDay(settings.pay_period_start_day); setPayPeriodLength(settings.pay_period_length_days); setDailyOvertimeThreshold(settings.daily_overtime_threshold); setWeeklyOvertimeThreshold(settings.weekly_overtime_threshold); - setOvertimeMultiplier(settings.overtime_multiplier); - setOvertimeNotifications(settings.overtime_notifications); + overtimeMultiplierRef.current = settings.overtime_multiplier; + overtimeNotificationsRef.current = settings.overtime_notifications; setNotificationEmails(settings.notification_emails ?? []); setNotificationSmsNumbers(settings.notification_sms_numbers ?? []); setEnableDailyAlerts(settings.enable_daily_alerts ?? true); setEnableWeeklyAlerts(settings.enable_weekly_alerts ?? true); - setDailyAlertThreshold(Math.round((settings.daily_alert_threshold ?? 0.80) * 100)); - setWeeklyAlertThreshold(Math.round((settings.weekly_alert_threshold ?? 0.80) * 100)); - setSendEndOfPeriodSummary(settings.send_end_of_period_summary ?? true); - setBrandName(settings.brand_name ?? "Farm"); + dailyAlertThresholdRef.current = Math.round((settings.daily_alert_threshold ?? 0.80) * 100); + weeklyAlertThresholdRef.current = Math.round((settings.weekly_alert_threshold ?? 0.80) * 100); + sendEndOfPeriodSummaryRef.current = settings.send_end_of_period_summary ?? true; + brandNameRef.current = settings.brand_name ?? "Farm"; } useEffect(() => { @@ -186,16 +182,16 @@ export default function SettingsSections({ brandId, workersOnly, tasksOnly }: Pr pay_period_length_days: payPeriodLength, daily_overtime_threshold: dailyOvertimeThreshold, weekly_overtime_threshold: weeklyOvertimeThreshold, - overtime_multiplier: overtimeMultiplier, - overtime_notifications: overtimeNotifications, + overtime_multiplier: overtimeMultiplierRef.current, + overtime_notifications: overtimeNotificationsRef.current, notification_emails: notificationEmails, notification_sms_numbers: notificationSmsNumbers, enable_daily_alerts: enableDailyAlerts, enable_weekly_alerts: enableWeeklyAlerts, - daily_alert_threshold: dailyAlertThreshold / 100, - weekly_alert_threshold: weeklyAlertThreshold / 100, - send_end_of_period_summary: sendEndOfPeriodSummary, - brand_name: brandName, + daily_alert_threshold: dailyAlertThresholdRef.current / 100, + weekly_alert_threshold: weeklyAlertThresholdRef.current / 100, + send_end_of_period_summary: sendEndOfPeriodSummaryRef.current, + brand_name: brandNameRef.current, }); setSettingsSaving(false); if (!result.success) { setSettingsError(result.error ?? "Failed to save"); return; } diff --git a/src/components/admin/ShippingSettingsForm.tsx b/src/components/admin/ShippingSettingsForm.tsx index e977a22..3c9746f 100644 --- a/src/components/admin/ShippingSettingsForm.tsx +++ b/src/components/admin/ShippingSettingsForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { getShippingSettings, saveShippingSettings, @@ -68,9 +68,9 @@ function PlatformAdminShippingSettingsForm({ // 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/StopEditForm.tsx b/src/components/admin/StopEditForm.tsx index 3b16524..cb9b6ee 100644 --- a/src/components/admin/StopEditForm.tsx +++ b/src/components/admin/StopEditForm.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, useRef } from "react"; import { useRouter } from "next/navigation"; import { updateStop } from "@/actions/stops/update-stop"; import { AdminInput, AdminTextInput, AdminSelect } from "./design-system/AdminFormElements"; @@ -38,7 +38,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp const { success: showSuccess, error: showError } = useToast(); const [saving, setSaving] = useState(false); const [error, setError] = useState(null); - const [saved, setSaved] = useState(false); + const savedRef = useRef(false); // Holds user edits only; missing keys fall back to the current prop so we // never copy the prop into useState. When the prop changes, the fallback @@ -147,7 +147,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp setSaving(true); setError(null); - setSaved(false); + savedRef.current = false; const result = await updateStop(stop.id, brand_id, { city, @@ -169,7 +169,7 @@ export default function StopEditForm({ stop, brands, onSaved }: StopEditFormProp } showSuccess("Stop updated", `${city}, ${state} has been saved`); - setSaved(true); + savedRef.current = true; setSaving(false); onSaved?.(); router.refresh(); diff --git a/src/components/admin/StopTableClient.tsx b/src/components/admin/StopTableClient.tsx index fe9ace8..3fdee14 100644 --- a/src/components/admin/StopTableClient.tsx +++ b/src/components/admin/StopTableClient.tsx @@ -89,12 +89,11 @@ const StopIconHeader = () => ( export default function StopTableClient({ stops, hideInternalFilterBar = false }: Props) { const router = useRouter(); const { success: showSuccess, error: showError } = useToast(); - const [isPending, startTransition] = useTransition(); + const [, startTransition] = useTransition(); const [search, setSearch] = useState(""); const [statusFilter, setStatusFilter] = useState<"all" | "active" | "inactive" | "draft">("all"); const [deleteError, setDeleteError] = useState(null); const [page, setPage] = useState(0); - const [filterVersion, setFilterVersion] = useState(0); const [selectedStops, setSelectedStops] = useState>(new Set()); const [bulkPublishing, setBulkPublishing] = useState(false); const [showImport, setShowImport] = useState(false); @@ -105,12 +104,6 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } const [viewMode, setViewMode] = useState("table"); const PAGE_SIZE = 25; - useEffect(() => { - startTransition(() => { - setFilterVersion((v) => v + 1); - }); - }, [page, statusFilter, search, sortField, sortDirection, startTransition]); - // Compute stats const stats = useMemo(() => { const now = new Date(); @@ -300,25 +293,25 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false }

Total

- {isPending ? : stats.total} + {stats.total}

Active

- {isPending ? : stats.active} + {stats.active}

Upcoming

- {isPending ? : stats.upcoming} + {stats.upcoming}

Drafts

- {isPending ? : stats.draft} + {stats.draft}

@@ -409,7 +402,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } selectedStops={selectedStops} onToggleSelectAll={toggleSelectAll} onToggleSelect={toggleStopSelection} - isLoading={isPending} + isLoading={false} search={search} statusFilter={statusFilter} /> @@ -419,7 +412,7 @@ export default function StopTableClient({ stops, hideInternalFilterBar = false } onEdit={setEditingStop} onDeleted={handleDeleted} onDeleteError={setDeleteError} - isLoading={isPending} + isLoading={false} search={search} statusFilter={statusFilter} /> diff --git a/src/components/admin/TimeTrackingSettingsClient.tsx b/src/components/admin/TimeTrackingSettingsClient.tsx index 0fcda4f..629bc7a 100644 --- a/src/components/admin/TimeTrackingSettingsClient.tsx +++ b/src/components/admin/TimeTrackingSettingsClient.tsx @@ -1,7 +1,7 @@ "use client"; /* eslint-disable react-hooks/set-state-in-effect */ -import { useState, useEffect, useCallback } from "react"; +import { useState, useEffect, useCallback, useRef } from "react"; function triggerDownload(url: string) { const a = document.createElement("a"); @@ -98,7 +98,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett const [workers, setWorkers] = useState([]); const [tasks, setTasks] = useState([]); - const [settings, setSettings] = useState(null); + const settingsRef = useRef(null); const [notificationLog, setNotificationLog] = useState([]); const [loading, setLoading] = useState(true); @@ -106,7 +106,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett const [payPeriodLength, setPayPeriodLength] = useState(7); const [dailyOvertimeThreshold, setDailyOvertimeThreshold] = useState(12); const [weeklyOvertimeThreshold, setWeeklyOvertimeThreshold] = useState(56); - const [overtimeMultiplier, setOvertimeMultiplier] = useState(1.5); + const overtimeMultiplierRef = useRef(1.5); const [overtimeNotifications, setOvertimeNotifications] = useState(true); const [settingsSaving, setSettingsSaving] = useState(false); const [settingsSaved, setSettingsSaved] = useState(false); @@ -116,10 +116,10 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett const [notificationSmsNumbers, setNotificationSmsNumbers] = useState([]); const [enableDailyAlerts, setEnableDailyAlerts] = useState(true); const [enableWeeklyAlerts, setEnableWeeklyAlerts] = useState(true); - const [dailyAlertThreshold, setDailyAlertThreshold] = useState(80); - const [weeklyAlertThreshold, setWeeklyAlertThreshold] = useState(80); - const [sendEndOfPeriodSummary, setSendEndOfPeriodSummary] = useState(true); - const [brandName, setBrandName] = useState("Farm"); + const dailyAlertThresholdRef = useRef(80); + const weeklyAlertThresholdRef = useRef(80); + const sendEndOfPeriodSummaryRef = useRef(true); + const brandNameRef = useRef("Farm"); const [newEmail, setNewEmail] = useState(""); const [newSms, setNewSms] = useState(""); @@ -151,23 +151,24 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett // Track the previous settings object so we can sync form state // inline during render when fresh data arrives from the server // (avoids the stale-frame window a useEffect would introduce). - const [prevSettings, setPrevSettings] = useState(null); - if (settings && settings !== prevSettings) { - setPrevSettings(settings); + const prevSettingsRef = useRef(null); + if (settingsRef.current && settingsRef.current !== prevSettingsRef.current) { + const settings = settingsRef.current; + prevSettingsRef.current = settings; setPayPeriodStartDay(settings.pay_period_start_day); setPayPeriodLength(settings.pay_period_length_days); setDailyOvertimeThreshold(settings.daily_overtime_threshold); setWeeklyOvertimeThreshold(settings.weekly_overtime_threshold); - setOvertimeMultiplier(settings.overtime_multiplier); + overtimeMultiplierRef.current = settings.overtime_multiplier; setOvertimeNotifications(settings.overtime_notifications); setNotificationEmails(settings.notification_emails ?? []); setNotificationSmsNumbers(settings.notification_sms_numbers ?? []); setEnableDailyAlerts(settings.enable_daily_alerts ?? true); setEnableWeeklyAlerts(settings.enable_weekly_alerts ?? true); - setDailyAlertThreshold(Math.round((settings.daily_alert_threshold ?? 0.80) * 100)); - setWeeklyAlertThreshold(Math.round((settings.weekly_alert_threshold ?? 0.80) * 100)); - setSendEndOfPeriodSummary(settings.send_end_of_period_summary ?? true); - setBrandName(settings.brand_name ?? "Farm"); + dailyAlertThresholdRef.current = Math.round((settings.daily_alert_threshold ?? 0.80) * 100); + weeklyAlertThresholdRef.current = Math.round((settings.weekly_alert_threshold ?? 0.80) * 100); + sendEndOfPeriodSummaryRef.current = settings.send_end_of_period_summary ?? true; + brandNameRef.current = settings.brand_name ?? "Farm"; } const load = useCallback(async () => { @@ -179,7 +180,7 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett ]); setWorkers(w); setTasks(t); - setSettings(s); + settingsRef.current = s; const log = await getTimeTrackingNotificationLog(brandId, 50); setNotificationLog(log); setLoading(false); @@ -198,16 +199,16 @@ export default function TimeTrackingSettingsClient({ brandId }: TimeTrackingSett pay_period_length_days: payPeriodLength, daily_overtime_threshold: dailyOvertimeThreshold, weekly_overtime_threshold: weeklyOvertimeThreshold, - overtime_multiplier: overtimeMultiplier, + overtime_multiplier: overtimeMultiplierRef.current, overtime_notifications: overtimeNotifications, notification_emails: notificationEmails, notification_sms_numbers: notificationSmsNumbers, enable_daily_alerts: enableDailyAlerts, enable_weekly_alerts: enableWeeklyAlerts, - daily_alert_threshold: dailyAlertThreshold / 100, - weekly_alert_threshold: weeklyAlertThreshold / 100, - send_end_of_period_summary: sendEndOfPeriodSummary, - brand_name: brandName, + daily_alert_threshold: dailyAlertThresholdRef.current / 100, + weekly_alert_threshold: weeklyAlertThresholdRef.current / 100, + send_end_of_period_summary: sendEndOfPeriodSummaryRef.current, + brand_name: brandNameRef.current, }); setSettingsSaving(false); if (!result.success) { setSettingsError(result.error ?? "Failed to save"); return; } diff --git a/src/components/admin/UsersPage.tsx b/src/components/admin/UsersPage.tsx index e67b374..a46c018 100644 --- a/src/components/admin/UsersPage.tsx +++ b/src/components/admin/UsersPage.tsx @@ -124,7 +124,7 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre const [deleteConfirmId, setDeleteConfirmId] = useState(null); const [actionMenuId, setActionMenuId] = useState(null); const [passwordModal, setPasswordModal] = useState(null); - const [resetLinkLoading, setResetLinkLoading] = useState(null); + const resetLinkLoadingRef = useRef(null); const [success, setSuccess] = useState(null); // Auto-dismiss the success banner after 6 seconds. @@ -327,17 +327,17 @@ export default function UsersPage({ initialUsers, brands, currentUser, onUserCre } async function handleSendResetLink(email: string) { - setResetLinkLoading(email); + resetLinkLoadingRef.current = email; setError(null); setSuccess(null); try { const { sendPasswordResetEmail } = await import("@/actions/admin/users"); const result = await sendPasswordResetEmail(email); - setResetLinkLoading(null); + resetLinkLoadingRef.current = null; if (result.error) throw new Error(result.error); setSuccess(`Password-reset email sent to ${email}.`); } catch (e: unknown) { - setResetLinkLoading(null); + resetLinkLoadingRef.current = null; setError(e instanceof Error ? e.message : "Failed to send reset email"); } } diff --git a/src/components/landing/HeroSection.tsx b/src/components/landing/HeroSection.tsx index ae9e609..50a58bf 100644 --- a/src/components/landing/HeroSection.tsx +++ b/src/components/landing/HeroSection.tsx @@ -24,18 +24,12 @@ export default function HeroSection() { const containerRef = useRef(null); const heroRef = useRef(null); const contentRef = useRef(null); - const [mounted, setMounted] = useState(false); const [scrollProgress, setScrollProgress] = useState(0); - useEffect(() => { - const timer = setTimeout(() => setMounted(true), 100); - return () => clearTimeout(timer); - }, []); - // ─── GSAP SCROLL ANIMATIONS ──────────────────────────────────────────────── // Resilient: respects reduced-motion, safe GSAP target checks, reliable counter impl, proper scope useEffect(() => { - if (!mounted || !heroRef.current || !contentRef.current) return; + if (!heroRef.current || !contentRef.current) return; const prefersReducedMotion = typeof window !== "undefined" && @@ -232,7 +226,7 @@ export default function HeroSection() { }, containerRef); return () => ctx.revert(); - }, [mounted]); + }, []); return ( <> diff --git a/src/components/route-trace/FsmaReportModal.tsx b/src/components/route-trace/FsmaReportModal.tsx index 5964431..b329bc8 100644 --- a/src/components/route-trace/FsmaReportModal.tsx +++ b/src/components/route-trace/FsmaReportModal.tsx @@ -181,9 +181,8 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) { // Track the most recent fetch signature so the effect below can // request fresh data when the user changes the date range or hits // the refresh button. - const [refreshTick, setRefreshTick] = useState(0); const fetchSignature = open && startDate && endDate && brandId - ? `${brandId}|${startDate}|${endDate}|${refreshTick}` + ? `${brandId}|${startDate}|${endDate}` : null; // Kicks off a fetch and writes the result into state. Stable enough @@ -226,7 +225,10 @@ export default function FsmaReportModal({ brandId }: { brandId: string }) { }, [fetchSignature, performFetch]); function fetchComplianceData() { - setRefreshTick((n) => n + 1); + // Inline the same logic as the effect above — performFetch with a + // fresh cancellation signal. + const signal = { cancelled: false }; + void performFetch(signal); } function handleDownload() { diff --git a/src/components/route-trace/LotCreateModal.tsx b/src/components/route-trace/LotCreateModal.tsx index 02a4d84..018d852 100644 --- a/src/components/route-trace/LotCreateModal.tsx +++ b/src/components/route-trace/LotCreateModal.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useTransition } from "react"; +import { useState, useTransition, useRef } from "react"; import { useRouter } from "next/navigation"; import { createHarvestLot } from "@/actions/route-trace/lots"; @@ -33,12 +33,10 @@ export default function LotCreateModal({ isOpen, onClose, brandId }: Props) { const [notes, setNotes] = useState(""); // Track the previous isOpen value to detect a transition from closed → open, - // which is when we want to reset the form. The initial value is a literal - // (not derived from a prop) so a stale useState copy can't occur if the - // parent passes a new isOpen value. - const [prevIsOpen, setPrevIsOpen] = useState(false); - if (isOpen !== prevIsOpen) { - setPrevIsOpen(isOpen); + // which is when we want to reset the form. + const prevIsOpenRef = useRef(false); + if (isOpen !== prevIsOpenRef.current) { + prevIsOpenRef.current = isOpen; if (isOpen) { setCropType(""); setVariety(""); diff --git a/src/components/route-trace/QRScanModal.tsx b/src/components/route-trace/QRScanModal.tsx index a0ddda0..e230d14 100644 --- a/src/components/route-trace/QRScanModal.tsx +++ b/src/components/route-trace/QRScanModal.tsx @@ -62,7 +62,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps) const [manualInput, setManualInput] = useState(""); const [cameraError, setCameraError] = useState(null); const [cameraStarting, setCameraStarting] = useState(true); - const [detected, setDetected] = useState(false); + const detectedRef = useRef(false); const [scanSuccess, setScanSuccess] = useState(false); const videoRef = useRef(null); const streamRef = useRef(null); @@ -147,15 +147,15 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps) // Scan loop const scan = async () => { - if (!mounted || detected || !detectorRef.current || !videoRef.current) return; + if (!mounted || detectedRef.current || !detectorRef.current || !videoRef.current) return; if (videoRef.current.readyState < 2) { scanRef.current = requestAnimationFrame(scan); return; } try { const barcodes = await detectorRef.current.detect(videoRef.current); - if (barcodes.length > 0 && !detected) { - setDetected(true); + if (barcodes.length > 0 && !detectedRef.current) { + detectedRef.current = true; setScanSuccess(true); const raw = barcodes[0].rawValue; if (streamRef.current) streamRef.current.getTracks().forEach(t => t.stop()); @@ -184,7 +184,7 @@ export default function QRScanModal({ onClose, onScanResult }: QRScanModalProps) if (scanRef.current) cancelAnimationFrame(scanRef.current); if (streamRef.current) streamRef.current.getTracks().forEach((t) => t.stop()); }; - }, [mode, detected]); + }, [mode]); function handleManualSubmit(e: React.FormEvent) { e.preventDefault(); diff --git a/src/components/time-tracking/TimeTrackingFieldClient.tsx b/src/components/time-tracking/TimeTrackingFieldClient.tsx index 497939a..ebd1c21 100644 --- a/src/components/time-tracking/TimeTrackingFieldClient.tsx +++ b/src/components/time-tracking/TimeTrackingFieldClient.tsx @@ -174,7 +174,7 @@ export default function TimeTrackingFieldClient({ }) { const [lang, setLang] = useState<"en" | "es">(() => (getLangCookie() as "en" | "es") ?? "en"); const [screen, setScreen] = useState("pin"); - const [session, setSession] = useState(null); + const sessionRef = useRef(null); const [tasks, setTasks] = useState([]); const [openEntry, setOpenEntry] = useState(null); const [payPeriod, setPayPeriod] = useState(null); @@ -219,7 +219,7 @@ export default function TimeTrackingFieldClient({ await loadPayPeriodRef.current(); return; } - setSession(stored); + sessionRef.current = stored; const open = await getOpenClockIn(brandId); await loadPayPeriodRef.current(); @@ -277,7 +277,7 @@ export default function TimeTrackingFieldClient({ setError(result.error ?? t.invalid_pin); return; } - setSession(result.session!); + sessionRef.current = result.session!; const [open, taskList] = await Promise.all([ getOpenClockIn(brandId), getTimeTrackingTasksField(brandId), @@ -303,7 +303,7 @@ export default function TimeTrackingFieldClient({ await fetch("/api/time-tracking/notify", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ brandId, workerId: session?.worker_id, workerName, dailyHours, weeklyHours }), + body: JSON.stringify({ brandId, workerId: sessionRef.current?.worker_id, workerName, dailyHours, weeklyHours }), }); } catch { // non-critical @@ -340,7 +340,7 @@ export default function TimeTrackingFieldClient({ }); await loadPayPeriod(); if (payPeriod) { - dispatchNotification(session?.name ?? taskName, payPeriod.daily_hours, payPeriod.weekly_hours); + dispatchNotification(sessionRef.current?.name ?? taskName, payPeriod.daily_hours, payPeriod.weekly_hours); } setScreen("working"); }; @@ -357,14 +357,14 @@ export default function TimeTrackingFieldClient({ setClockOutResult({ clock_out: result.clock_out!, total_minutes: result.total_minutes ?? 0 }); await loadPayPeriod(); if (payPeriod) { - dispatchNotification(session?.name ?? openEntry?.task_name ?? "Worker", payPeriod.daily_hours, payPeriod.weekly_hours); + dispatchNotification(sessionRef.current?.name ?? openEntry?.task_name ?? "Worker", payPeriod.daily_hours, payPeriod.weekly_hours); } setScreen("clocked_out"); }; const handleLogout = async () => { await logoutTimeTracking(); - setSession(null); + sessionRef.current = null; setOpenEntry(null); setPayPeriod(null); setClockOutResult(null); diff --git a/src/components/water/WaterFieldClient.tsx b/src/components/water/WaterFieldClient.tsx index c870b57..f7addd1 100644 --- a/src/components/water/WaterFieldClient.tsx +++ b/src/components/water/WaterFieldClient.tsx @@ -1,7 +1,7 @@ "use client"; import Image from "next/image"; -import { useState, useEffect, Suspense, useMemo, useEffectEvent } from "react"; +import { useState, useEffect, Suspense, useMemo, useEffectEvent, useRef } from "react"; import { verifyWaterPin, submitWaterEntry, @@ -146,7 +146,7 @@ function WaterFieldInner() { const [success, setSuccess] = useState(false); // Photo state - const [photoFile, setPhotoFile] = useState(null); + const photoFileRef = useRef(null); const [photoPreview, setPhotoPreview] = useState(null); // GPS state @@ -256,14 +256,14 @@ function WaterFieldInner() { setError("Only JPG or PNG photos allowed"); return; } - setPhotoFile(file); + photoFileRef.current = file; const reader = new FileReader(); reader.onload = (ev) => setPhotoPreview(ev.target?.result as string); reader.readAsDataURL(file); } function removePhoto() { - setPhotoFile(null); + photoFileRef.current = null; setPhotoPreview(null); } @@ -274,9 +274,9 @@ function WaterFieldInner() { setError(""); let photoUrl: string | undefined; - if (photoFile) { + if (photoFileRef.current) { const formData = new FormData(); - formData.append("file", photoFile); + formData.append("file", photoFileRef.current); formData.append("bucket", "water-log-photos"); try { const uploadRes = await fetch("/api/water-photo-upload", { @@ -318,7 +318,7 @@ function WaterFieldInner() { setSuccess(true); setMeasurement(""); setNotes(""); - setPhotoFile(null); + photoFileRef.current = null; setPhotoPreview(null); setLatitude(null); setLongitude(null); @@ -337,7 +337,7 @@ function WaterFieldInner() { setSelectedHeadgate(""); setMeasurement(""); setNotes(""); - setPhotoFile(null); + photoFileRef.current = null; setPhotoPreview(null); setLatitude(null); setLongitude(null);