From 2e4fe2a8fe65f06a61a9a54cc01435d6eb491bde Mon Sep 17 00:00:00 2001 From: BOHA Date: Sun, 7 Jun 2026 18:28:49 +0200 Subject: [PATCH] fix(offers): stop heartbeat 401 spam on dead session + de-dup item keys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three console issues observed while idle on an offer: 1. Heartbeat flooded the console with 401s. The lock-keepalive interval fired apiFetch(...).catch(() => {}) every 10s; apiFetch RESOLVES with a 401 (it doesn't throw), so .catch never saw it and the interval never stopped. Once the refresh token had also expired, every tick 401'd forever. Now we inspect the response and clearInterval on a 401 (which only occurs after a failed refresh = session truly gone); the healthy path still returns 200 and keeps ticking (verified in Chrome). 2. Duplicate React key "item-1" in the line-items table. The form/items/ sections useState initializers restored the create-mode draft UNCONDITIONALLY — even in edit mode — and reused the draft's stale item _key values while itemKeyCounter restarts at 0 each load, so an added row collided. Now the draft is only restored when !isEdit, and restored items are re-keyed with fresh unique _keys. 3. framer-motion "motion() is deprecated" warning — PageEnter used the deprecated motion(Box) factory; switched to motion.create(Box). tsc -b --noEmit, npm run build, vitest 152/152 clean. Console verified clean in Chrome (only benign dev notices remain). Co-Authored-By: Claude Opus 4.8 (1M context) --- src/admin/pages/OfferDetail.tsx | 32 +++++++++++++++++++++++++------- src/admin/ui/PageEnter.tsx | 2 +- 2 files changed, 26 insertions(+), 8 deletions(-) diff --git a/src/admin/pages/OfferDetail.tsx b/src/admin/pages/OfferDetail.tsx index 986a6ad..96fc44a 100644 --- a/src/admin/pages/OfferDetail.tsx +++ b/src/admin/pages/OfferDetail.tsx @@ -543,7 +543,9 @@ export default function OfferDetail() { const [saving, setSaving] = useState(false); const [errors, setErrors] = useState>({}); const [form, setForm] = useState(() => { - const draft = loadOfferDraft(); + // The draft is the unsaved-NEW-offer buffer — never restore it over an + // existing offer in edit mode (it would also leak its stale item keys). + const draft = isEdit ? null : loadOfferDraft(); if (draft?.form) { return { quotation_number: @@ -566,14 +568,20 @@ export default function OfferDetail() { return emptyForm; }); const [items, setItems] = useState(() => { - const draft = loadOfferDraft(); + const draft = isEdit ? null : loadOfferDraft(); if (Array.isArray(draft?.items) && draft.items.length > 0) { - return draft.items as OfferItem[]; + // Re-key restored items: their persisted _key values are stale and the + // counter restarts at 0 each load, so reusing them would collide with + // newly-added rows (duplicate React key / dnd-kit id → the warning). + return (draft.items as OfferItem[]).map((it) => ({ + ...it, + _key: `item-${++itemKeyCounter.current}`, + })); } return [emptyItem()]; }); const [sections, setSections] = useState(() => { - const draft = loadOfferDraft(); + const draft = isEdit ? null : loadOfferDraft(); if (Array.isArray(draft?.sections) && draft.sections.length > 0) { return draft.sections as ScopeSection[]; } @@ -723,9 +731,19 @@ export default function OfferDetail() { return; heartbeatRef.current = setInterval(() => { - apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }).catch( - () => {}, - ); + apiFetch(`${API_BASE}/offers/${id}/heartbeat`, { method: "POST" }) + .then((res) => { + // 401 here means the access token expired AND the refresh failed + // (session truly gone — apiFetch retries with a refreshed token on a + // recoverable 401 and would return 200). Stop the interval so we don't + // spam a 401 every 10s while the user sits idle on a dead session; + // apiFetch has already flagged session-expired for the next action. + if (res.status === 401 && heartbeatRef.current) { + clearInterval(heartbeatRef.current); + heartbeatRef.current = null; + } + }) + .catch(() => {}); }, 10 * 1000); // every 10 seconds return () => { diff --git a/src/admin/ui/PageEnter.tsx b/src/admin/ui/PageEnter.tsx index 4857ab5..13824ea 100644 --- a/src/admin/ui/PageEnter.tsx +++ b/src/admin/ui/PageEnter.tsx @@ -3,7 +3,7 @@ import { motion, useReducedMotion, type Variants } from "framer-motion"; import Box from "@mui/material/Box"; import type { SxProps, Theme } from "@mui/material/styles"; -const MotionBox = motion(Box); +const MotionBox = motion.create(Box); const container: Variants = { hidden: {},