Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
5fadce7
feat: add visible set change detection
Mosas2000 May 13, 2026
ae0a351
feat: add request ID tracking for cancellation
Mosas2000 May 13, 2026
7646ed2
feat: reset loading state immediately on change
Mosas2000 May 13, 2026
11794eb
feat: improve state reconciliation on set change
Mosas2000 May 13, 2026
1e374e7
feat: validate request ID in response handlers
Mosas2000 May 13, 2026
c4101a8
feat: reset request tracking in clearEnrichment
Mosas2000 May 13, 2026
74ca1a1
fix: update effect dependency array
Mosas2000 May 13, 2026
4a8720e
test: add rapid pagination test
Mosas2000 May 13, 2026
047ee5e
test: add stale loading indicator prevention test
Mosas2000 May 13, 2026
77ae297
test: add rapid filtering changes test
Mosas2000 May 13, 2026
1906cd5
test: fix filtering test expectations
Mosas2000 May 13, 2026
84c4732
feat: only fetch uncached IDs on set change
Mosas2000 May 13, 2026
33e8274
fix: remove tipMessages from effect dependencies to prevent infinite …
Mosas2000 May 13, 2026
f485bd5
docs: clarify clearCounter dependency purpose
Mosas2000 May 13, 2026
86b39fe
docs: document tipMessagesRef purpose
Mosas2000 May 13, 2026
eab106f
docs: clarify request ID validation logic
Mosas2000 May 13, 2026
b686f75
docs: add comprehensive enrichment state reset documentation
Mosas2000 May 13, 2026
28ec20c
docs: update hook JSDoc with key features
Mosas2000 May 13, 2026
6107e3c
docs: explain state reconciliation strategy
Mosas2000 May 13, 2026
71bf0c0
refactor: add comment for uncached ID filtering
Mosas2000 May 13, 2026
b78a7d7
docs: add issue 340 fix summary
Mosas2000 May 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
100 changes: 100 additions & 0 deletions frontend/docs/ENRICHMENT_STATE_RESET.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
# Enrichment State Reset on Visible Set Changes

## Problem

The `useSelectiveMessageEnrichment` hook was keeping previous enrichment state while the visible tip set was changing quickly during pagination or filtering. This caused:

- Stale loading indicators remaining visible
- Stale message mappings persisting across page changes
- Infinite loops when `tipMessages` was included in effect dependencies

## Solution

### 1. Visible Set Change Detection

Added detection logic to identify when the visible tip set has materially changed:

```javascript
const visibleSetChanged = useMemo(() => {
const current = new Set(visibleTipIds);
const previous = new Set(previousIdsRef.current);

if (current.size !== previous.size) return true;

for (const id of current) {
if (!previous.has(id)) return true;
}

return false;
}, [visibleTipIds, clearCounter]);
```

### 2. Request ID Tracking

Implemented request ID tracking to properly cancel stale requests:

```javascript
const requestId = ++activeRequestIdRef.current;

// In promise handlers:
if (requestId !== activeRequestIdRef.current) return;
```

This ensures that only the most recent request updates the state, preventing race conditions during rapid pagination.

### 3. Ref-Based Cache Tracking

Used a ref to track the cache state without triggering effect re-runs:

```javascript
const tipMessagesRef = useRef({});

// Update both state and ref
setTipMessages(prev => {
const updated = { ...prev, ...obj };
tipMessagesRef.current = updated;
return updated;
});

// Check cache using ref to avoid dependency loop
const uncachedIds = visibleTipIds.filter(id => !tipMessagesRef.current[id]);
```

This prevents the infinite loop that occurred when `tipMessages` was in the effect dependencies.

### 4. State Reconciliation

When the visible set changes:

- **No overlap**: Clear all cached messages (complete page change)
- **Partial overlap**: Keep only messages for IDs still visible
- **Subset**: Reuse all cached messages (filtering to fewer items)

### 5. Loading State Management

Improved loading state management to handle edge cases:

- Set loading to false when visible set is empty
- Set loading to false when all IDs are cached
- Set loading to true only when fetching uncached IDs
- Always set loading to false in finally block for active requests

## Testing

Added comprehensive tests for:

- Rapid pagination without stale state
- Stale loading indicator prevention
- Rapid filtering changes
- State reconciliation on partial set changes
- Manual clear and re-fetch behavior

All 14 tests pass successfully.

## Impact

- Eliminates stale loading indicators during rapid navigation
- Prevents stale message data from appearing on wrong pages
- Fixes infinite loop that caused performance issues
- Maintains cache efficiency for overlapping page changes
- Improves user experience during pagination and filtering
103 changes: 103 additions & 0 deletions frontend/docs/ISSUE_340_SUMMARY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
# Issue #340: Reset Stale Enrichment State on Visible Set Changes

## Issue Description

The `useSelectiveMessageEnrichment` hook was keeping previous enrichment state while the visible tip set was changing quickly during pagination or filtering, causing stale loading indicators and message mappings.

## Root Causes

1. **Infinite Loop**: Including `tipMessages` in effect dependencies caused the effect to re-run whenever messages were fetched, creating an infinite loop
2. **Stale State**: No detection of visible set changes meant old state persisted during rapid navigation
3. **Race Conditions**: Multiple concurrent requests could update state in the wrong order
4. **Loading State Issues**: Loading indicators remained visible even after requests completed

## Solution Summary

### Core Changes

1. **Visible Set Change Detection** (commit 5fadce7)
- Added logic to detect when visible tip IDs change materially
- Uses Set comparison for efficient change detection

2. **Request ID Tracking** (commits ae0a351, 1e374e7)
- Implemented request ID system to identify and cancel stale requests
- Only the most recent request updates state

3. **Ref-Based Cache Tracking** (commit 33e8274)
- Used `tipMessagesRef` to track cache without triggering effect re-runs
- Removed `tipMessages` from effect dependencies to prevent infinite loop

4. **State Reconciliation** (commit 11794eb)
- Clear cache on complete page changes (no overlap)
- Filter cache on partial changes (some overlap)
- Reuse cache on subset changes (filtering)

5. **Loading State Management** (commit 7646ed2)
- Reset loading immediately on visible set change
- Set loading false when all IDs are cached
- Proper cleanup in finally block

### Testing

Added comprehensive tests (commits 4a8720e, 047ee5e, 77ae297, 1906cd5):
- Rapid pagination without stale state
- Stale loading indicator prevention
- Rapid filtering changes
- State reconciliation on partial set changes

All 14 tests pass successfully.

### Documentation

- Comprehensive change documentation (commit b686f75)
- Updated hook JSDoc (commit 28ec20c)
- Inline code comments (commits f485bd5, 86b39fe, eab106f, 6107e3c, 71bf0c0)

## Impact

✅ Eliminates stale loading indicators during rapid navigation
✅ Prevents stale message data from appearing on wrong pages
✅ Fixes infinite loop that caused performance issues
✅ Maintains cache efficiency for overlapping page changes
✅ Improves user experience during pagination and filtering

## Commits

Total: 20 professional commits following conventional commit format

1. feat: add visible set change detection
2. feat: add request ID tracking for cancellation
3. feat: reset loading state immediately on change
4. feat: improve state reconciliation on set change
5. feat: validate request ID in response handlers
6. feat: reset request tracking in clearEnrichment
7. fix: update effect dependency array
8. test: add rapid pagination test
9. test: add stale loading indicator prevention test
10. test: add rapid filtering changes test
11. test: fix filtering test expectations
12. feat: only fetch uncached IDs on set change
13. fix: remove tipMessages from effect dependencies to prevent infinite loop
14. docs: clarify clearCounter dependency purpose
15. docs: document tipMessagesRef purpose
16. docs: clarify request ID validation logic
17. docs: add comprehensive enrichment state reset documentation
18. docs: update hook JSDoc with key features
19. docs: explain state reconciliation strategy
20. refactor: add comment for uncached ID filtering

## Files Changed

- `frontend/src/hooks/useSelectiveMessageEnrichment.js` - Core implementation
- `frontend/src/hooks/useSelectiveMessageEnrichment.test.js` - Test coverage
- `frontend/docs/ENRICHMENT_STATE_RESET.md` - Technical documentation
- `frontend/docs/ISSUE_340_SUMMARY.md` - This summary

## Testing Instructions

```bash
cd frontend
npm test -- useSelectiveMessageEnrichment.test.js
```

All 14 tests should pass.
109 changes: 81 additions & 28 deletions frontend/src/hooks/useSelectiveMessageEnrichment.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,13 @@
*
* Message cache is persistent across hook re-renders to minimize redundant
* fetches as the visible set changes.
*
* Key features:
* - Detects visible set changes to reset stale state
* - Tracks request IDs to prevent race conditions
* - Uses ref-based cache tracking to avoid infinite loops
* - Reconciles state on partial set changes
* - Handles rapid pagination and filtering gracefully
*/

import { useEffect, useState, useRef, useMemo, useCallback } from 'react';
Expand All @@ -28,8 +35,11 @@ export function useSelectiveMessageEnrichment(visibleTips = []) {
const [tipMessages, setTipMessages] = useState({});
const [loading, setLoading] = useState(false);
const [error, setError] = useState(null);
const cancelledRef = useRef(false);
const previousIdsRef = useRef([]);
const activeRequestIdRef = useRef(0);
// Ref to track cache state without triggering effect re-runs
const tipMessagesRef = useRef({});
const [clearCounter, setClearCounter] = useState(0);

// Extract unique, non-zero tip IDs from the currently visible set
const visibleTipIds = useMemo(
Expand All @@ -40,63 +50,101 @@ export function useSelectiveMessageEnrichment(visibleTips = []) {
[visibleTips]
);

// Detect if the visible set has changed materially
// clearCounter forces recalculation when clearEnrichment is called
const visibleSetChanged = useMemo(() => {
const current = new Set(visibleTipIds);
const previous = new Set(previousIdsRef.current);

if (current.size !== previous.size) return true;

for (const id of current) {
if (!previous.has(id)) return true;
}

return false;
}, [visibleTipIds, clearCounter]);

useEffect(() => {
if (visibleTipIds.length === 0) {
setLoading(false);
return;
}

const hasNewIds = visibleTipIds.length !== previousIdsRef.current.length ||
visibleTipIds.some((id, i) => id !== previousIdsRef.current[i]);

if (!hasNewIds) {
if (!visibleSetChanged) {
return;
}

let cancelled = false;
cancelledRef.current = false;
const requestId = ++activeRequestIdRef.current;

const prevSet = new Set(previousIdsRef.current);
const currentSet = new Set(visibleTipIds);
const hasOverlap = visibleTipIds.some(id => prevSet.has(id));

Promise.resolve().then(() => {
if (cancelled || cancelledRef.current) return;

setLoading(true);
// Reconcile cached state based on overlap with previous set
// - No overlap: complete page change, clear all cache
// - Partial overlap: keep only messages for visible IDs
if (!hasOverlap && previousIdsRef.current.length > 0) {
setTipMessages({});
tipMessagesRef.current = {};
} else if (hasOverlap) {
setTipMessages(prev => {
const filtered = {};
for (const id of currentSet) {
if (prev[id]) {
filtered[id] = prev[id];
}
}
tipMessagesRef.current = filtered;
return filtered;
});
}

previousIdsRef.current = visibleTipIds;

// Determine which IDs need fetching from the API
const uncachedIds = visibleTipIds.filter(id => !tipMessagesRef.current[id]);

if (uncachedIds.length === 0) {
setLoading(false);
setError(null);
return;
}

const prevSet = new Set(previousIdsRef.current);
const hasOverlap = visibleTipIds.some(id => prevSet.has(id));
if (!hasOverlap && previousIdsRef.current.length > 0) {
setTipMessages({});
}

previousIdsRef.current = visibleTipIds;
});
setLoading(true);
setError(null);

const marker = createEnrichmentMarker();

fetchTipMessages(visibleTipIds)
fetchTipMessages(uncachedIds)
.then(messageMap => {
if (cancelled || cancelledRef.current) return;
// Only update state if this is still the active request
if (requestId !== activeRequestIdRef.current) return;
const obj = {};
messageMap.forEach((v, k) => { obj[k] = v; });
setTipMessages(prev => ({ ...prev, ...obj }));
marker.stop(visibleTipIds.length, messageMap.size);
setTipMessages(prev => {
const updated = { ...prev, ...obj };
tipMessagesRef.current = updated;
return updated;
});
marker.stop(uncachedIds.length, messageMap.size);
})
.catch(err => {
if (!cancelled && !cancelledRef.current) {
if (requestId === activeRequestIdRef.current) {
console.warn('Failed to fetch visible tip messages:', err.message || err);
setError(err.message || 'Failed to load messages');
}
})
.finally(() => {
if (!cancelled && !cancelledRef.current) {
if (requestId === activeRequestIdRef.current) {
setLoading(false);
}
});

return () => {
cancelled = true;
cancelledRef.current = true;
// Don't increment here, just let the next effect run increment it
};
}, [visibleTipIds]);
}, [visibleTipIds, visibleSetChanged]);

/**
* Re-map the visible tips to include their fetched messages.
Expand All @@ -116,7 +164,12 @@ export function useSelectiveMessageEnrichment(visibleTips = []) {
*/
const clearEnrichment = useCallback(() => {
setTipMessages({});
tipMessagesRef.current = {};
previousIdsRef.current = [];
activeRequestIdRef.current++;
setLoading(false);
setError(null);
setClearCounter(c => c + 1);
}, []);

return {
Expand Down
Loading
Loading