Skip to content

Commit 2361daa

Browse files
committed
fix unseen state race condition bug
1 parent 2196e1b commit 2361daa

File tree

3 files changed

+128
-96
lines changed

3 files changed

+128
-96
lines changed

src/App.jsx

Lines changed: 101 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ import HelpModal from "./components/HelpModal.jsx";
112112

113113
import { cacheGraphImages } from './swRegistration';
114114

115+
import { getNotesForTarget, hasContent } from './utils/notes.js';
116+
115117
function uniqueSorted(arr) {
116118
return Array.from(new Set(arr)).sort();
117119
}
@@ -593,9 +595,8 @@ function App() {
593595
}, [clearVisitedForMap, mapName]);
594596

595597
// Typewriter control: opens viewer immediately but delays typewriter until zoom completes
596-
const [typewriterEnabled, setTypewriterEnabled] = useState(false);
597-
const [shouldMountTypewriter, setShouldMountTypewriter] = useState(false); // NEW: Controls if TypewriterText component should mount at all
598-
const typewriterRef = useRef(false);
598+
// Store session data indexed by targetId to avoid race conditions with remounting
599+
const [viewSessions, setViewSessions] = useState({});
599600
// Track the latest zoom start to ignore stale completions
600601
const latestZoomTokenRef = useRef(null);
601602

@@ -610,7 +611,7 @@ function App() {
610611
);
611612

612613
// ✅ ADD THIS RIGHT AFTER THE HOOK DECLARATIONS
613-
console.log('🏁 [App] Component rendered with:', { cdnBaseUrl, mapName, nodeCount: graphData.nodes.length, firstNodeImage: graphData.nodes[0]?.imageUrl });
614+
//// console.log('🏁 [App] Component rendered with:', { cdnBaseUrl, mapName, nodeCount: graphData.nodes.length, firstNodeImage: graphData.nodes[0]?.imageUrl });
614615

615616
/** ---------- handlers ---------- **/
616617

@@ -660,109 +661,125 @@ useEffect(() => {
660661
const isTransitioningRef = useRef(false);
661662

662663
// Note viewing handlers - defined early to avoid circular dependencies
663-
// Update handleStartNoteViewing to use the flag
664+
// Update handleStartNoteViewing to use the flag
664665
const handleStartNoteViewing = useCallback((targetId, targetType) => {
665-
// Check if we're transitioning between nodes
666-
const isTransitioning = noteViewingTarget && noteViewingTarget !== targetId;
666+
// Check if we're switching between targets
667+
const switching = !!noteViewingTarget && noteViewingTarget !== targetId;
667668

668-
if (isTransitioning) {
669+
if (switching) {
670+
// Set switching flag to prevent camera zoom-out on close
671+
isSwitchingTargetsRef.current = true;
669672
isTransitioningRef.current = true;
670-
// Clear the flag after the transition
673+
// Clear transition flag after a brief delay
671674
setTimeout(() => {
672675
isTransitioningRef.current = false;
673676
}, 100);
674677
}
675678

676-
// Treat clicking another node while already viewing as a "switch", not a close+open
677-
const switching = !!noteViewingTarget && noteViewingTarget !== targetId;
678-
isSwitchingTargetsRef.current = switching;
679-
pendingViewTargetRef.current = targetId;
680-
// While opening/switching, suppress any empty-selection close from Cytoscape
681-
suppressEmptyCloseRef.current = true;
679+
// console.log('🎬 [handleStartNoteViewing] Starting:', {
680+
// targetId,
681+
// targetType,
682+
// switching,
683+
// previousTarget: noteViewingTarget,
684+
// timestamp: performance.now()
685+
// });
686+
687+
const unseenAtOpen = isUnseen(targetId) && hasContent(graphData, targetId);
688+
const sessionId = Symbol('view-session');
689+
690+
// console.log('🔍 [handleStartNoteViewing] Unseen check:', {
691+
// targetId,
692+
// targetType,
693+
// isUnseen: isUnseen(targetId),
694+
// hasContent: hasContent(graphData, targetId),
695+
// unseenAtOpen,
696+
// ZOOM_TO_SELECTION
697+
// });
698+
699+
// Create session for this target
700+
setViewSessions(prev => ({
701+
...prev,
702+
[targetId]: {
703+
id: sessionId,
704+
targetId,
705+
isUnseenAtOpen: unseenAtOpen,
706+
typewriterReady: !ZOOM_TO_SELECTION // if no zoom, we can typewriter immediately
707+
}
708+
}));
682709

683-
// ---- Compute first-open flag for typewriter logic ----
684-
// Only typewriter the first time we open a given id (and only if there's content)
685-
const hasContent =
686-
(Array.isArray(graphData?.notes?.[targetId]) ? graphData.notes[targetId].join('\n\n') : (graphData?.notes?.[targetId] || '')).trim().length > 0;
687-
const unseenNow = isUnseen(targetId);
688-
typewriterRef.current = Boolean(unseenNow && hasContent);
710+
// Open/switch the viewer
711+
dispatchAppState({
712+
type: ACTION_TYPES.START_NOTE_VIEWING,
713+
payload: { targetId, targetType }
714+
});
689715

690-
// Open the viewer immediately, but don't mount TypewriterText yet.
691-
// We'll mount it after the zoom animation completes.
692-
setTypewriterEnabled(false);
693-
setShouldMountTypewriter(false); // Don't mount TypewriterText during animation
716+
// Mark visited immediately
717+
if (targetType === 'node') markNodeVisited(targetId);
718+
else markEdgeVisited(targetId);
694719

695-
// Zoom to selection if feature is enabled and we're in playing mode
696720
if (ZOOM_TO_SELECTION && targetId) {
697-
printDebug("📹 [StartNoteViewing] Beginning zoom sequence for:", targetId);
721+
const token = sessionId; // reuse the session id as token
698722

699-
// Only save the "original camera" once, on the first zoom-in. For switches, reuse it.
723+
// Determine camera behavior based on switching state
700724
const shouldSaveCamera = !hasOriginalCamera();
701-
const zoomStartTime = performance.now();
702725

703-
const zoomPromise = fitToSelection([targetId], {
726+
fitToSelection([targetId], {
704727
animate: true,
705728
padding: 80,
706729
targetHalf: 'top',
707730
saveCamera: shouldSaveCamera,
708731
zoomLevel: 'close'
709-
});
710-
711-
// Mount TypewriterText and enable it only when zoom completes AND we're still viewing this same target
712-
const token = Symbol('zoom-token');
713-
latestZoomTokenRef.current = token;
714-
zoomPromise.finally(() => {
715-
const zoomEndTime = performance.now();
716-
const totalZoomTime = zoomEndTime - zoomStartTime;
717-
718-
if (latestZoomTokenRef.current === token) {
719-
printDebug("✅ [StartNoteViewing] Zoom complete, adding small delay before mounting typewriter:", {
720-
targetId,
721-
totalTime: totalZoomTime.toFixed(2) + 'ms',
722-
willTypewriter: typewriterRef.current
732+
}).finally(() => {
733+
// Only enable typewriter for the current live session
734+
setTimeout(() => {
735+
// console.log('⏰ [handleStartNoteViewing] Setting typewriterReady=true for session:', {
736+
// token: token.toString(),
737+
// targetId,
738+
// switching
739+
// });
740+
setViewSessions(prev => {
741+
const session = prev[targetId];
742+
const shouldUpdate = session && session.id === token;
743+
// console.log('📝 [setViewSessions] Update check:', {
744+
// shouldUpdate,
745+
// prevId: session?.id?.toString(),
746+
// token: token.toString(),
747+
// prevTargetId: session?.targetId,
748+
// targetId
749+
// });
750+
if (!shouldUpdate) return prev;
751+
return {
752+
...prev,
753+
[targetId]: { ...session, typewriterReady: true }
754+
};
723755
});
724756

725-
// Add a small delay to ensure viewport streaming has fully resumed
726-
// and any pending renders have completed before mounting TypewriterText
727-
setTimeout(() => {
728-
if (latestZoomTokenRef.current === token) {
729-
printDebug("⌨️ [StartNoteViewing] Mounting and enabling typewriter now");
730-
setShouldMountTypewriter(true); // Mount the component
731-
setTypewriterEnabled(true); // Enable animation
732-
} else {
733-
printDebug("⚠️ [StartNoteViewing] Token changed during delay - NOT enabling typewriter");
734-
}
735-
}, 100); // 100ms grace period for rendering to settle
736-
} else {
737-
printDebug("⚠️ [StartNoteViewing] Zoom complete but token mismatch - NOT enabling typewriter");
738-
}
757+
// Clear switching flag after zoom completes
758+
if (switching) {
759+
isSwitchingTargetsRef.current = false;
760+
}
761+
}, 100); // tiny grace to let rendering settle (keep your 100ms)
739762
});
740763
} else {
741-
printDebug("📹 [StartNoteViewing] No zoom needed, mounting and enabling typewriter immediately");
742-
// No zoom → mount and enable typewriter immediately
743-
setShouldMountTypewriter(true);
744-
setTypewriterEnabled(true);
764+
// No zoom, clear switching flag immediately
765+
if (switching) {
766+
setTimeout(() => {
767+
isSwitchingTargetsRef.current = false;
768+
}, 0);
769+
}
745770
}
746-
747-
// Open (or switch) the viewer
748-
dispatchAppState({
749-
type: ACTION_TYPES.START_NOTE_VIEWING,
750-
payload: { targetId, targetType }
751-
});
752-
753-
// Mark as visited
754-
if (targetType === 'node') markNodeVisited(targetId);
755-
else if (targetType === 'edge') markEdgeVisited(targetId);
756-
757-
// NOTE: we now clear the guards when the new selection actually lands
758-
// (see handleNodeSelectionChange).
759-
}, [fitToSelection, noteViewingTarget, hasOriginalCamera, graphData, isUnseen, markNodeVisited, markEdgeVisited]);
771+
}, [graphData, isUnseen, fitToSelection, hasOriginalCamera, markNodeVisited, markEdgeVisited, noteViewingTarget]);
760772

761773
// Update handleCloseNoteViewing to check the transition flag
762774
const handleCloseNoteViewing = useCallback(() => {
763-
// Disable and unmount typewriter immediately on close
764-
setTypewriterEnabled(false);
765-
setShouldMountTypewriter(false); // Unmount TypewriterText component
775+
// Clear session for the closing target
776+
if (noteViewingTarget) {
777+
setViewSessions(prev => {
778+
const { [noteViewingTarget]: _, ...rest } = prev;
779+
return rest;
780+
});
781+
}
782+
766783
latestZoomTokenRef.current = null; // invalidate any pending completion
767784

768785
// Prevent duplicate execution and transitions
@@ -800,7 +817,7 @@ useEffect(() => {
800817
pendingViewTargetRef.current = null;
801818
isSwitchingTargetsRef.current = false;
802819
}
803-
}, [mode, restoreOriginalCamera, hasOriginalCamera, clearCytoscapeSelections]);
820+
}, [mode, restoreOriginalCamera, hasOriginalCamera, clearCytoscapeSelections, noteViewingTarget]);
804821

805822
// Debug modal open/close now via modalOps
806823

@@ -1392,12 +1409,12 @@ useEffect(() => {
13921409
/>
13931410

13941411
<NoteViewerModal
1412+
key={noteViewingTarget || 'no-target'}
13951413
targetId={noteViewingTarget}
1396-
notes={noteViewingTarget ? (graphData.notes?.[noteViewingTarget] || []) : []}
1414+
notes={noteViewingTarget ? getNotesForTarget(graphData, noteViewingTarget) : []}
13971415
onClose={handleCloseNoteViewing}
1398-
shouldTypewriter={typewriterEnabled && typewriterRef.current}
1399-
shouldShowText={!typewriterRef.current || typewriterEnabled}
1400-
shouldMountTypewriter={shouldMountTypewriter}
1416+
isUnseenAtOpen={viewSessions[noteViewingTarget]?.isUnseenAtOpen || false}
1417+
typewriterReady={viewSessions[noteViewingTarget]?.typewriterReady || false}
14011418
/>
14021419

14031420
<HelpModal

src/components/NoteViewerModal.jsx

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -18,12 +18,19 @@ const BLUE_OUTLINE_COLOR = "#5878b6";
1818

1919
function NoteViewerModal({
2020
targetId,
21-
notes, // array of note strings for this target
21+
notes, // array of strings
2222
onClose,
23-
shouldTypewriter = false,
24-
shouldShowText = true, // controls whether text should be visible at all
25-
shouldMountTypewriter = false // NEW: controls whether TypewriterText component should mount
23+
isUnseenAtOpen, // boolean
24+
typewriterReady // boolean
2625
}) {
26+
// console.log('🎭 [NoteViewerModal] Rendered with props:', {
27+
// targetId,
28+
// noteCount: notes.length,
29+
// isUnseenAtOpen,
30+
// typewriterReady,
31+
// timestamp: performance.now()
32+
// });
33+
2734
// Add keyboard event listener for Escape key
2835
useEffect(() => {
2936
const handleKeyDown = (event) => {
@@ -108,14 +115,10 @@ function NoteViewerModal({
108115
wordBreak: "break-word",
109116
fontSize: "14px",
110117
}}>
111-
{shouldShowText ? (
112-
// Only mount TypewriterText if shouldMountTypewriter is true
113-
shouldTypewriter && shouldMountTypewriter ? (
114-
<TypewriterText text={note} enabled />
115-
) : (
116-
note
117-
)
118-
) : null}
118+
{isUnseenAtOpen
119+
? (typewriterReady ? <TypewriterText text={note} enabled /> : null)
120+
: note
121+
}
119122
</li>
120123
))}
121124
</ul>

src/utils/notes.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
// src/utils/notes.js
2+
export function getNotesForTarget(graphData, id) {
3+
const raw = graphData?.notes?.[id];
4+
return Array.isArray(raw) ? raw : (raw ? [raw] : []);
5+
}
6+
7+
export function hasContent(graphData, id) {
8+
return getNotesForTarget(graphData, id)
9+
.join('\n\n')
10+
.trim()
11+
.length > 0;
12+
}

0 commit comments

Comments
 (0)