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:
@@ -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 () => {
|
||||||
|
|||||||
@@ -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: {},
|
||||||
|
|||||||
Reference in New Issue
Block a user