Fix lock banner: use viewLockQuery.data directly instead of syncing through state

Root cause: the previous approach synced poll data into lockInfo via a useEffect.
When the user selected an event with cached lock data, both the poll-data effect and
the event-change reset effect ran in the same render cycle. The event-change effect
ran second (effects are ordered by definition) and cleared lockInfo to null. On the
next render, viewLockQuery.data hadn't changed (TanStack Query structural sharing
returns same reference), so the poll-data effect never re-fired. Result: lockInfo
stayed null, banner stayed hidden until the next polling interval returned new data.

Fix: derive activeLockInfo directly from viewLockQuery.data (structural sharing
means it's always the latest authoritative value from TanStack Query) with lockInfo
as a fallback for the 423-error path only. Also add refetchIntervalInBackground:true
and refetchOnMount:'always' to ensure polling doesn't pause on tab switch and always
fires a fresh fetch when the component mounts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Kyle 2026-03-06 17:47:26 +08:00
parent 3dcf9d1671
commit 8f777dd15a

View File

@ -264,6 +264,7 @@ export default function EventDetailPanel({
const [locationSearch, setLocationSearch] = useState('');
// Poll lock status in view mode for shared events (Stream A: real-time lock awareness)
// lockInfo is only set from the 423 error path; poll data (viewLockQuery.data) is used directly.
const viewLockQuery = useQuery({
queryKey: ['event-lock', event?.id],
queryFn: async () => {
@ -274,18 +275,22 @@ export default function EventDetailPanel({
},
enabled: !!isSharedEvent && !!event && typeof event.id === 'number' && !isEditing && !isCreating,
refetchInterval: 5_000,
refetchIntervalInBackground: true,
refetchOnMount: 'always',
});
// Show/hide lock banner proactively in view mode (poll data always authoritative)
// Clear 423-error lockInfo when poll confirms lock is gone
useEffect(() => {
if (!viewLockQuery.data) return;
if (viewLockQuery.data.locked) {
setLockInfo(viewLockQuery.data);
} else {
if (viewLockQuery.data && !viewLockQuery.data.locked) {
setLockInfo(null);
}
}, [viewLockQuery.data]);
// Derived: authoritative lock state — poll data wins, 423 error lockInfo as fallback
const activeLockInfo: EventLockInfo | null =
(viewLockQuery.data?.locked ? viewLockQuery.data : null) ??
(lockInfo?.locked ? lockInfo : null);
const isRecurring = !!(event?.is_recurring || event?.parent_event_id);
// Permission helpers
@ -580,8 +585,8 @@ export default function EventDetailPanel({
size="icon"
className="h-7 w-7"
onClick={handleEditStart}
disabled={isAcquiringLock || !!(lockInfo && lockInfo.locked)}
title={lockInfo && lockInfo.locked ? `Locked by ${lockInfo.locked_by_name || 'another user'}` : 'Edit event'}
disabled={isAcquiringLock || !!activeLockInfo}
title={activeLockInfo ? `Locked by ${activeLockInfo.locked_by_name || 'another user'}` : 'Edit event'}
>
{isAcquiringLock ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Pencil className="h-3.5 w-3.5" />}
</Button>
@ -629,12 +634,12 @@ export default function EventDetailPanel({
{/* Body */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-3">
{/* Lock banner */}
{lockInfo && lockInfo.locked && (
{/* Lock banner — shown when activeLockInfo reports a lock (poll-authoritative) */}
{activeLockInfo && (
<EventLockBanner
lockedByName={lockInfo.locked_by_name || 'another user'}
expiresAt={lockInfo.expires_at}
isPermanent={lockInfo.is_permanent}
lockedByName={activeLockInfo.locked_by_name || 'another user'}
expiresAt={activeLockInfo.expires_at}
isPermanent={activeLockInfo.is_permanent}
/>
)}