fix(offers): stop heartbeat 401 spam on dead session + de-dup item keys

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) <noreply@anthropic.com>
This commit is contained in:
BOHA
2026-06-07 18:28:49 +02:00
parent 27c690285a
commit 2e4fe2a8fe
2 changed files with 26 additions and 8 deletions

View File

@@ -543,7 +543,9 @@ export default function OfferDetail() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [errors, setErrors] = useState<Record<string, string | undefined>>({}); const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [form, setForm] = useState<OfferForm>(() => { const [form, setForm] = useState<OfferForm>(() => {
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) { if (draft?.form) {
return { return {
quotation_number: quotation_number:
@@ -566,14 +568,20 @@ export default function OfferDetail() {
return emptyForm; return emptyForm;
}); });
const [items, setItems] = useState<OfferItem[]>(() => { const [items, setItems] = useState<OfferItem[]>(() => {
const draft = loadOfferDraft(); const draft = isEdit ? null : loadOfferDraft();
if (Array.isArray(draft?.items) && draft.items.length > 0) { 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()]; return [emptyItem()];
}); });
const [sections, setSections] = useState<ScopeSection[]>(() => { const [sections, setSections] = useState<ScopeSection[]>(() => {
const draft = loadOfferDraft(); const draft = isEdit ? null : loadOfferDraft();
if (Array.isArray(draft?.sections) && draft.sections.length > 0) { if (Array.isArray(draft?.sections) && draft.sections.length > 0) {
return draft.sections as ScopeSection[]; return draft.sections as ScopeSection[];
} }
@@ -723,9 +731,19 @@ export default function OfferDetail() {
return; return;
heartbeatRef.current = setInterval(() => { 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 }, 10 * 1000); // every 10 seconds
return () => { return () => {

View File

@@ -3,7 +3,7 @@ import { motion, useReducedMotion, type Variants } from "framer-motion";
import Box from "@mui/material/Box"; import Box from "@mui/material/Box";
import type { SxProps, Theme } from "@mui/material/styles"; import type { SxProps, Theme } from "@mui/material/styles";
const MotionBox = motion(Box); const MotionBox = motion.create(Box);
const container: Variants = { const container: Variants = {
hidden: {}, hidden: {},