From 672a4cf118270f8a5769241b91a8ba5f297689d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 07:07:03 +0000 Subject: [PATCH 1/5] fix: Handle null/undefined queue items to prevent TypeError in computeQueueStateHash The error "undefined is not an object (evaluating 'e.uuid')" occurred when the queue array contained null/undefined items. This fix: - Adds defensive filtering in computeQueueStateHash to handle corrupted queue items - Filters out null/undefined items when receiving FullSync events from server - Skips QueueItemAdded events with null/undefined items - Triggers automatic resync when corrupted items are detected in queue state https://claude.ai/code/session_016qWNjtHhPUmPXZgSUa6sCz --- .../persistent-session-context.tsx | 21 ++++++++++++++++++- packages/web/app/utils/hash.ts | 5 +++-- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/packages/web/app/components/persistent-session/persistent-session-context.tsx b/packages/web/app/components/persistent-session/persistent-session-context.tsx index 69f7d6d2..da708ae2 100644 --- a/packages/web/app/components/persistent-session/persistent-session-context.tsx +++ b/packages/web/app/components/persistent-session/persistent-session-context.tsx @@ -294,13 +294,20 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> switch (event.__typename) { case 'FullSync': - setQueueState(event.state.queue as LocalClimbQueueItem[]); + // Filter out any undefined/null items that may have been introduced by state corruption + setQueueState((event.state.queue as LocalClimbQueueItem[]).filter(item => item != null)); setCurrentClimbQueueItem(event.state.currentClimbQueueItem as LocalClimbQueueItem | null); // Reset sequence tracking on full sync updateLastReceivedSequence(event.sequence); setLastReceivedStateHash(event.state.stateHash); break; case 'QueueItemAdded': + // Skip if item is undefined/null to prevent state corruption + if (event.addedItem == null) { + console.warn('[PersistentSession] Received QueueItemAdded with null/undefined item, skipping'); + updateLastReceivedSequence(event.sequence); + break; + } setQueueState((prev) => { const newQueue = [...prev]; if (event.position !== undefined && event.position >= 0) { @@ -350,8 +357,20 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> // Keep state hash in sync with local state after delta events // This ensures hash verification compares against current state, not stale FullSync hash + // Also detects corrupted items and triggers resync if found useEffect(() => { if (!session) return; // Only update hash when connected + + // Check for corrupted (null/undefined) items in the queue + const hasCorruptedItems = queue.some(item => item == null); + if (hasCorruptedItems) { + console.warn('[PersistentSession] Detected null/undefined items in queue, triggering resync'); + if (triggerResyncRef.current) { + triggerResyncRef.current(); + } + return; // Skip hash update - resync will provide correct state + } + const newHash = computeQueueStateHash(queue, currentClimbQueueItem?.uuid || null); setLastReceivedStateHash(newHash); }, [session, queue, currentClimbQueueItem]); diff --git a/packages/web/app/utils/hash.ts b/packages/web/app/utils/hash.ts index 0ca7c0df..0f1ce826 100644 --- a/packages/web/app/utils/hash.ts +++ b/packages/web/app/utils/hash.ts @@ -22,11 +22,12 @@ export function fnv1aHash(str: string): string { * Used for periodic verification against server state */ export function computeQueueStateHash( - queue: Array<{ uuid: string }>, + queue: Array<{ uuid: string } | undefined | null>, currentItemUuid: string | null ): string { // Sort queue UUIDs for deterministic ordering - const queueUuids = queue.map(item => item.uuid).sort().join(','); + // Filter out any undefined/null items that may have been introduced by state corruption + const queueUuids = queue.filter((item): item is { uuid: string } => item != null && item.uuid != null).map(item => item.uuid).sort().join(','); const currentUuid = currentItemUuid || 'null'; // Create canonical string representation From e937b4c29f94ec79ebabd9ea6f943a9e03fb4040 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 07:13:01 +0000 Subject: [PATCH 2/5] chore: Update package-lock.json https://claude.ai/code/session_016qWNjtHhPUmPXZgSUa6sCz --- package-lock.json | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 39cd228d..9d1374c9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -4565,7 +4565,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.57.0.tgz", "integrity": "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright": "1.57.0" @@ -7263,7 +7263,7 @@ "version": "19.2.7", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.7.tgz", "integrity": "sha512-MWtvHrGZLFttgeEj28VXHxpmwYbor/ATPYbBfSFZEIRK0ecCFLl2Qo55z52Hss+UV9CRN7trSeq1zbgx7YDWWg==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -8011,7 +8011,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.1.0.tgz", "integrity": "sha512-ZMANVnAixE6AWWnPzlW2KpUrxhm9woycYvPOo67jWHyFowASTEd9s+QN1EIMsSDtwhIxN4sWE1jotpuDUIgyIw==", - "dev": true, + "devOptional": true, "hasInstallScript": true, "license": "MIT", "dependencies": { @@ -10158,7 +10158,7 @@ "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", "integrity": "sha512-LA4ZjwlnUblHVgq0oBF3Jl/6h/Nvs5fzBLwdEF4nuxnFdsfajde4WfxtJr3CaiH+F6ewcIB/q4jQ4UzPyid+CQ==", - "dev": true, + "devOptional": true, "license": "MIT", "bin": { "node-gyp-build": "bin.js", @@ -10647,7 +10647,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz", "integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "dependencies": { "playwright-core": "1.57.0" @@ -10666,7 +10666,7 @@ "version": "1.57.0", "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz", "integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==", - "dev": true, + "devOptional": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" @@ -10898,7 +10898,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -10918,7 +10917,6 @@ "version": "19.2.3", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz", "integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==", - "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" @@ -11285,7 +11283,6 @@ "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "dev": true, "license": "MIT" }, "node_modules/scroll-into-view-if-needed": { From 21105a85045a33a0056c86bd2ece3466cac40bf6 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 08:06:23 +0000 Subject: [PATCH 3/5] fix: Add cooldown to corruption-triggered resyncs to prevent infinite loops If the server returns corrupted data repeatedly, the resync would trigger in a tight loop. This adds: - 30 second cooldown between corruption-triggered resyncs - Local filtering of corrupted items during cooldown period - Logging of cooldown status for debugging https://claude.ai/code/session_016qWNjtHhPUmPXZgSUa6sCz --- .../persistent-session-context.tsx | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/packages/web/app/components/persistent-session/persistent-session-context.tsx b/packages/web/app/components/persistent-session/persistent-session-context.tsx index da708ae2..fc442381 100644 --- a/packages/web/app/components/persistent-session/persistent-session-context.tsx +++ b/packages/web/app/components/persistent-session/persistent-session-context.tsx @@ -245,6 +245,9 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> const mountedRef = useRef(false); // Ref to store reconnect handler for use by hash verification const triggerResyncRef = useRef<(() => void) | null>(null); + // Cooldown tracking for corruption-triggered resyncs to prevent infinite loops + const lastCorruptionResyncRef = useRef(0); + const CORRUPTION_RESYNC_COOLDOWN_MS = 30000; // 30 second cooldown between corruption resyncs // Event subscribers const queueEventSubscribersRef = useRef void>>(new Set()); @@ -364,7 +367,22 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> // Check for corrupted (null/undefined) items in the queue const hasCorruptedItems = queue.some(item => item == null); if (hasCorruptedItems) { + const now = Date.now(); + const timeSinceLastResync = now - lastCorruptionResyncRef.current; + + if (timeSinceLastResync < CORRUPTION_RESYNC_COOLDOWN_MS) { + // Still in cooldown - filter corrupted items locally instead of resyncing + console.warn( + `[PersistentSession] Detected null/undefined items in queue, but resync on cooldown ` + + `(${Math.round((CORRUPTION_RESYNC_COOLDOWN_MS - timeSinceLastResync) / 1000)}s remaining). ` + + `Filtering locally.` + ); + setQueueState(prev => prev.filter(item => item != null)); + return; + } + console.warn('[PersistentSession] Detected null/undefined items in queue, triggering resync'); + lastCorruptionResyncRef.current = now; if (triggerResyncRef.current) { triggerResyncRef.current(); } From 3a23035893f89e81a4677ee8242614167190c078 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 08:12:51 +0000 Subject: [PATCH 4/5] fix: Improve corruption handling - prevent infinite loops and improve Sentry visibility - Keep strict type signature in computeQueueStateHash with internal defensive filtering - Add TSDoc explaining the defensive filtering behavior - Move CORRUPTION_RESYNC_COOLDOWN_MS to module scope (avoid recreation on render) - Add isFilteringCorruptedItemsRef to prevent useEffect re-trigger loop when filtering - Change console.warn to console.error for corruption events so Sentry captures them https://claude.ai/code/session_016qWNjtHhPUmPXZgSUa6sCz --- .../persistent-session-context.tsx | 19 +++++++++++++++---- packages/web/app/utils/hash.ts | 14 +++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/packages/web/app/components/persistent-session/persistent-session-context.tsx b/packages/web/app/components/persistent-session/persistent-session-context.tsx index fc442381..b1a48d66 100644 --- a/packages/web/app/components/persistent-session/persistent-session-context.tsx +++ b/packages/web/app/components/persistent-session/persistent-session-context.tsx @@ -64,6 +64,9 @@ const DEFAULT_BACKEND_URL = process.env.NEXT_PUBLIC_WS_URL || null; // Board names to check if we're on a board route - use centralized constant const BOARD_NAMES = SUPPORTED_BOARDS; +// Cooldown for corruption-triggered resyncs to prevent infinite loops +const CORRUPTION_RESYNC_COOLDOWN_MS = 30000; // 30 seconds + // Session type matching the GraphQL response export interface Session { id: string; @@ -247,7 +250,8 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> const triggerResyncRef = useRef<(() => void) | null>(null); // Cooldown tracking for corruption-triggered resyncs to prevent infinite loops const lastCorruptionResyncRef = useRef(0); - const CORRUPTION_RESYNC_COOLDOWN_MS = 30000; // 30 second cooldown between corruption resyncs + // Track if we're currently filtering corrupted items to prevent useEffect re-trigger loop + const isFilteringCorruptedItemsRef = useRef(false); // Event subscribers const queueEventSubscribersRef = useRef void>>(new Set()); @@ -307,7 +311,7 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> case 'QueueItemAdded': // Skip if item is undefined/null to prevent state corruption if (event.addedItem == null) { - console.warn('[PersistentSession] Received QueueItemAdded with null/undefined item, skipping'); + console.error('[PersistentSession] Received QueueItemAdded with null/undefined item, skipping'); updateLastReceivedSequence(event.sequence); break; } @@ -364,6 +368,12 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> useEffect(() => { if (!session) return; // Only update hash when connected + // Skip if we're currently filtering corrupted items to prevent re-trigger loop + if (isFilteringCorruptedItemsRef.current) { + isFilteringCorruptedItemsRef.current = false; + return; + } + // Check for corrupted (null/undefined) items in the queue const hasCorruptedItems = queue.some(item => item == null); if (hasCorruptedItems) { @@ -372,16 +382,17 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }> if (timeSinceLastResync < CORRUPTION_RESYNC_COOLDOWN_MS) { // Still in cooldown - filter corrupted items locally instead of resyncing - console.warn( + console.error( `[PersistentSession] Detected null/undefined items in queue, but resync on cooldown ` + `(${Math.round((CORRUPTION_RESYNC_COOLDOWN_MS - timeSinceLastResync) / 1000)}s remaining). ` + `Filtering locally.` ); + isFilteringCorruptedItemsRef.current = true; setQueueState(prev => prev.filter(item => item != null)); return; } - console.warn('[PersistentSession] Detected null/undefined items in queue, triggering resync'); + console.error('[PersistentSession] Detected null/undefined items in queue, triggering resync'); lastCorruptionResyncRef.current = now; if (triggerResyncRef.current) { triggerResyncRef.current(); diff --git a/packages/web/app/utils/hash.ts b/packages/web/app/utils/hash.ts index 0f1ce826..c997af42 100644 --- a/packages/web/app/utils/hash.ts +++ b/packages/web/app/utils/hash.ts @@ -20,14 +20,22 @@ export function fnv1aHash(str: string): string { /** * Compute a deterministic hash of queue state * Used for periodic verification against server state + * + * Note: Internally filters out null/undefined items as a defensive measure + * against state corruption. This allows the function to handle corrupted + * queue arrays gracefully without crashing. */ export function computeQueueStateHash( - queue: Array<{ uuid: string } | undefined | null>, + queue: Array<{ uuid: string }>, currentItemUuid: string | null ): string { // Sort queue UUIDs for deterministic ordering - // Filter out any undefined/null items that may have been introduced by state corruption - const queueUuids = queue.filter((item): item is { uuid: string } => item != null && item.uuid != null).map(item => item.uuid).sort().join(','); + // Defensive filter for null/undefined items that may have been introduced by state corruption + const queueUuids = queue + .filter((item): item is { uuid: string } => item != null && typeof item === 'object' && item.uuid != null) + .map(item => item.uuid) + .sort() + .join(','); const currentUuid = currentItemUuid || 'null'; // Create canonical string representation From a3933810e3269e84ca79f545e1d8416c090f3343 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Feb 2026 08:20:06 +0000 Subject: [PATCH 5/5] fix: Improve type safety, add tests, and document corruption detection - Update computeQueueStateHash type to explicitly accept null/undefined items - Add comprehensive unit tests for hash utilities including corruption handling - Document Queue Item Corruption Detection in websocket-implementation.md - Explains detection points, resync cooldown, and implementation details - Adds mermaid diagram showing the corruption recovery flow https://claude.ai/code/session_016qWNjtHhPUmPXZgSUa6sCz --- docs/websocket-implementation.md | 55 +++++- packages/web/app/utils/__tests__/hash.test.ts | 173 ++++++++++++++++++ packages/web/app/utils/hash.ts | 8 +- 3 files changed, 231 insertions(+), 5 deletions(-) create mode 100644 packages/web/app/utils/__tests__/hash.test.ts diff --git a/docs/websocket-implementation.md b/docs/websocket-implementation.md index f009deed..8f77ebfe 100644 --- a/docs/websocket-implementation.md +++ b/docs/websocket-implementation.md @@ -643,7 +643,60 @@ sequenceDiagram - Sequence numbers must increment by 1 - Hash updated after each delta event -### 6. Subscription Error / Complete +### 6. Queue Item Corruption Detection + +The client detects and recovers from corrupted queue items (null/undefined entries) that may occur due to server bugs, network issues, or state corruption. + +```mermaid +sequenceDiagram + participant C as Client + participant PS as PersistentSession + participant S as Server + + Note over C: useEffect runs on queue change + + C->>C: Check for null/undefined items + + alt No corrupted items + C->>C: Compute and update state hash + else Corrupted items detected + C->>C: Check resync cooldown (30s) + + alt Within cooldown + C->>C: Filter items locally + C->>C: Log error (Sentry) + Note over C: Prevents infinite loops + else Cooldown expired + C->>C: Log error (Sentry) + C->>S: Trigger resync + S->>C: FullSync event + C->>C: Apply clean state + end + end +``` + +**Corruption sources:** +- Server sends malformed queue data +- State corruption during delta sync +- Race conditions in event handling + +**Detection points:** +1. **FullSync handler**: Filters null items when receiving initial/full state +2. **QueueItemAdded handler**: Skips events with null items +3. **State hash effect**: Detects corruption in current queue state + +**Resync cooldown:** +- 30 second cooldown between corruption-triggered resyncs +- Prevents infinite loop if server keeps returning corrupted data +- During cooldown: filter corrupted items locally instead of resyncing +- All corruption events logged at `console.error` level for Sentry visibility + +**Implementation:** +- `computeQueueStateHash()` defensively filters null/undefined items +- `isFilteringCorruptedItemsRef` prevents useEffect re-trigger loops +- `lastCorruptionResyncRef` tracks cooldown timing + +### 7. Subscription Error / Complete ```mermaid sequenceDiagram diff --git a/packages/web/app/utils/__tests__/hash.test.ts b/packages/web/app/utils/__tests__/hash.test.ts new file mode 100644 index 00000000..c27525a4 --- /dev/null +++ b/packages/web/app/utils/__tests__/hash.test.ts @@ -0,0 +1,173 @@ +import { describe, it, expect } from 'vitest'; +import { fnv1aHash, computeQueueStateHash } from '../hash'; + +describe('hash utilities', () => { + describe('fnv1aHash', () => { + it('should return consistent hash for same input', () => { + const input = 'test-string'; + const hash1 = fnv1aHash(input); + const hash2 = fnv1aHash(input); + + expect(hash1).toBe(hash2); + }); + + it('should return different hashes for different inputs', () => { + const hash1 = fnv1aHash('input-1'); + const hash2 = fnv1aHash('input-2'); + + expect(hash1).not.toBe(hash2); + }); + + it('should return 8-character hex string', () => { + const hash = fnv1aHash('test'); + + expect(hash).toMatch(/^[0-9a-f]{8}$/); + }); + + it('should handle empty string', () => { + const hash = fnv1aHash(''); + + expect(hash).toMatch(/^[0-9a-f]{8}$/); + }); + }); + + describe('computeQueueStateHash', () => { + it('should return consistent hash for same queue state', () => { + const queue = [{ uuid: 'item-1' }, { uuid: 'item-2' }]; + const currentItemUuid = 'item-1'; + + const hash1 = computeQueueStateHash(queue, currentItemUuid); + const hash2 = computeQueueStateHash(queue, currentItemUuid); + + expect(hash1).toBe(hash2); + }); + + it('should return different hash when queue changes', () => { + const queue1 = [{ uuid: 'item-1' }, { uuid: 'item-2' }]; + const queue2 = [{ uuid: 'item-1' }, { uuid: 'item-3' }]; + const currentItemUuid = 'item-1'; + + const hash1 = computeQueueStateHash(queue1, currentItemUuid); + const hash2 = computeQueueStateHash(queue2, currentItemUuid); + + expect(hash1).not.toBe(hash2); + }); + + it('should return different hash when currentItemUuid changes', () => { + const queue = [{ uuid: 'item-1' }, { uuid: 'item-2' }]; + + const hash1 = computeQueueStateHash(queue, 'item-1'); + const hash2 = computeQueueStateHash(queue, 'item-2'); + + expect(hash1).not.toBe(hash2); + }); + + it('should return same hash regardless of queue order (sorted internally)', () => { + const queue1 = [{ uuid: 'item-1' }, { uuid: 'item-2' }]; + const queue2 = [{ uuid: 'item-2' }, { uuid: 'item-1' }]; + const currentItemUuid = 'item-1'; + + const hash1 = computeQueueStateHash(queue1, currentItemUuid); + const hash2 = computeQueueStateHash(queue2, currentItemUuid); + + expect(hash1).toBe(hash2); + }); + + it('should handle null currentItemUuid', () => { + const queue = [{ uuid: 'item-1' }]; + + const hash = computeQueueStateHash(queue, null); + + expect(hash).toMatch(/^[0-9a-f]{8}$/); + }); + + it('should handle empty queue', () => { + const hash = computeQueueStateHash([], 'item-1'); + + expect(hash).toMatch(/^[0-9a-f]{8}$/); + }); + + it('should handle empty queue with null currentItemUuid', () => { + const hash = computeQueueStateHash([], null); + + expect(hash).toMatch(/^[0-9a-f]{8}$/); + }); + + // Corruption handling tests + describe('corruption handling', () => { + it('should filter out null items from queue', () => { + const queue = [{ uuid: 'item-1' }, null, { uuid: 'item-2' }]; + const cleanQueue = [{ uuid: 'item-1' }, { uuid: 'item-2' }]; + + const hashWithNull = computeQueueStateHash(queue, 'item-1'); + const hashClean = computeQueueStateHash(cleanQueue, 'item-1'); + + expect(hashWithNull).toBe(hashClean); + }); + + it('should filter out undefined items from queue', () => { + const queue = [{ uuid: 'item-1' }, undefined, { uuid: 'item-2' }]; + const cleanQueue = [{ uuid: 'item-1' }, { uuid: 'item-2' }]; + + const hashWithUndefined = computeQueueStateHash(queue, 'item-1'); + const hashClean = computeQueueStateHash(cleanQueue, 'item-1'); + + expect(hashWithUndefined).toBe(hashClean); + }); + + it('should filter out items with null uuid', () => { + const queue = [ + { uuid: 'item-1' }, + { uuid: null as unknown as string }, + { uuid: 'item-2' }, + ]; + const cleanQueue = [{ uuid: 'item-1' }, { uuid: 'item-2' }]; + + const hashWithNullUuid = computeQueueStateHash(queue, 'item-1'); + const hashClean = computeQueueStateHash(cleanQueue, 'item-1'); + + expect(hashWithNullUuid).toBe(hashClean); + }); + + it('should filter out items with undefined uuid', () => { + const queue = [ + { uuid: 'item-1' }, + { uuid: undefined as unknown as string }, + { uuid: 'item-2' }, + ]; + const cleanQueue = [{ uuid: 'item-1' }, { uuid: 'item-2' }]; + + const hashWithUndefinedUuid = computeQueueStateHash(queue, 'item-1'); + const hashClean = computeQueueStateHash(cleanQueue, 'item-1'); + + expect(hashWithUndefinedUuid).toBe(hashClean); + }); + + it('should handle queue with all corrupted items', () => { + const queue = [null, undefined, { uuid: null as unknown as string }]; + + const hash = computeQueueStateHash(queue, 'item-1'); + + // Should be equivalent to empty queue + const emptyHash = computeQueueStateHash([], 'item-1'); + expect(hash).toBe(emptyHash); + }); + + it('should not crash with mixed valid and corrupted items', () => { + const queue = [ + null, + { uuid: 'item-1' }, + undefined, + { uuid: 'item-2' }, + { uuid: null as unknown as string }, + { uuid: 'item-3' }, + ]; + + expect(() => computeQueueStateHash(queue, 'item-1')).not.toThrow(); + + const hash = computeQueueStateHash(queue, 'item-1'); + expect(hash).toMatch(/^[0-9a-f]{8}$/); + }); + }); + }); +}); diff --git a/packages/web/app/utils/hash.ts b/packages/web/app/utils/hash.ts index c997af42..97e1333d 100644 --- a/packages/web/app/utils/hash.ts +++ b/packages/web/app/utils/hash.ts @@ -21,12 +21,12 @@ export function fnv1aHash(str: string): string { * Compute a deterministic hash of queue state * Used for periodic verification against server state * - * Note: Internally filters out null/undefined items as a defensive measure - * against state corruption. This allows the function to handle corrupted - * queue arrays gracefully without crashing. + * Accepts potentially corrupted queue arrays containing null/undefined items + * and filters them out before computing the hash. This defensive approach + * prevents crashes when state corruption occurs. */ export function computeQueueStateHash( - queue: Array<{ uuid: string }>, + queue: Array<{ uuid: string } | null | undefined>, currentItemUuid: string | null ): string { // Sort queue UUIDs for deterministic ordering