Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
51 changes: 49 additions & 2 deletions packages/web/app/components/graphql-queue/QueueContext.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client';

import React, { useContext, createContext, ReactNode, useCallback, useMemo, useState, useEffect } from 'react';
import React, { useContext, createContext, ReactNode, useCallback, useMemo, useState, useEffect, useRef } from 'react';
import { useSearchParams, useRouter, usePathname } from 'next/navigation';
import { v4 as uuidv4 } from 'uuid';
import { useQueueReducer } from '../queue-control/reducer';
Expand Down Expand Up @@ -67,6 +67,14 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children }: G
const sessionIdFromUrl = searchParams.get('session');
const [activeSessionId, setActiveSessionId] = useState<string | null>(sessionIdFromUrl);

// Ref to store the initial queue to sync when starting a new session
// This captures the queue state before the connection overwrites it with backend state
const pendingInitialQueueRef = useRef<{
queue: ClimbQueueItem[];
currentClimbQueueItem: ClimbQueueItem | null;
sessionId: string;
} | null>(null);

// Sync activeSessionId with URL changes (e.g., when navigating to a shared link)
useEffect(() => {
// Only update activeSessionId from URL if:
Expand Down Expand Up @@ -240,6 +248,33 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children }: G
return unsubscribe;
}, [isPersistentSessionActive, persistentSession, dispatch]);

// Extract connection state values that we need for the sync effect
// These must be extracted before the effect so they can be used as dependencies
const hasConnectedForSync = persistentSession.hasConnected;
const isLeaderForSync = persistentSession.isLeader;

// Sync pending initial queue after connecting to a new session
// This handles the case where user starts a session with existing queue items
useEffect(() => {
// Only run when we've just connected to a session AND we're the leader
// All conditions must be met before we attempt the sync
if (!isPersistentSessionActive || !hasConnectedForSync || !isLeaderForSync) return;

// Check if we have a pending initial queue for this session
const pending = pendingInitialQueueRef.current;
if (!pending || pending.sessionId !== sessionId) return;

// Clear the pending ref immediately to prevent duplicate syncs
pendingInitialQueueRef.current = null;

// Sync the initial queue to the server
// This will broadcast a FullSync event to all clients (including ourselves)
console.log('[QueueContext] Syncing initial queue to server:', pending.queue.length, 'items');
persistentSession.setQueue(pending.queue, pending.currentClimbQueueItem).catch((error) => {
console.error('[QueueContext] Failed to sync initial queue:', error);
});
}, [isPersistentSessionActive, hasConnectedForSync, isLeaderForSync, sessionId, persistentSession]);

// Use persistent session values when active
const clientId = isPersistentSessionActive ? persistentSession.clientId : null;
const isLeader = isPersistentSessionActive ? persistentSession.isLeader : false;
Expand All @@ -257,6 +292,18 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children }: G
// Generate a new session ID
const newSessionId = uuidv4();

// IMPORTANT: Capture the current queue BEFORE the connection is established.
// When we connect, the backend will return an empty queue (new session),
// which would overwrite our local queue. We store it here so we can sync it
// to the server after connecting.
if (state.queue.length > 0 || state.currentClimbQueueItem) {
pendingInitialQueueRef.current = {
queue: state.queue,
currentClimbQueueItem: state.currentClimbQueueItem,
sessionId: newSessionId,
};
}

// Update URL with session parameter
const params = new URLSearchParams(searchParams.toString());
params.set('session', newSessionId);
Expand All @@ -277,7 +324,7 @@ export const GraphQLQueueProvider = ({ parsedParams, boardDetails, children }: G

return newSessionId;
},
[backendUrl, pathname, router, searchParams],
[backendUrl, pathname, router, searchParams, state.queue, state.currentClimbQueueItem],
);

const joinSessionHandler = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -211,10 +211,20 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }>
// Handle queue events internally
const handleQueueEvent = useCallback((event: ClientQueueEvent) => {
switch (event.__typename) {
case 'FullSync':
setQueueState(event.state.queue as LocalClimbQueueItem[]);
setCurrentClimbQueueItem(event.state.currentClimbQueueItem as LocalClimbQueueItem | null);
case 'FullSync': {
const newQueue = event.state.queue as LocalClimbQueueItem[];
let newCurrentClimbQueueItem = event.state.currentClimbQueueItem as LocalClimbQueueItem | null;

// Validate that current climb is in the queue
// If not, clear it to prevent inconsistent state
if (newCurrentClimbQueueItem && !newQueue.some(item => item.uuid === newCurrentClimbQueueItem?.uuid)) {
newCurrentClimbQueueItem = null;
}

setQueueState(newQueue);
setCurrentClimbQueueItem(newCurrentClimbQueueItem);
break;
}
case 'QueueItemAdded':
setQueueState((prev) => {
const newQueue = [...prev];
Expand All @@ -228,6 +238,8 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }>
break;
case 'QueueItemRemoved':
setQueueState((prev) => prev.filter((item) => item.uuid !== event.uuid));
// Clear current climb if it was removed
setCurrentClimbQueueItem((prev) => prev?.uuid === event.uuid ? null : prev);
break;
case 'QueueReordered':
setQueueState((prev) => {
Expand Down Expand Up @@ -510,9 +522,10 @@ export const PersistentSessionProvider: React.FC<{ children: React.ReactNode }>

const deactivateSession = useCallback(() => {
if (DEBUG) console.log('[PersistentSession] Deactivating session');
// Only clear the active session, NOT the queue state.
// The queue should persist for local use after leaving party mode.
// The queue state will be synced to local queue by the QueueContext effect.
setActiveSession(null);
setQueueState([]);
setCurrentClimbQueueItem(null);
}, []);

// Local queue management functions
Expand Down
32 changes: 26 additions & 6 deletions packages/web/app/components/queue-control/reducer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,40 @@ export function queueReducer(state: QueueState, action: QueueAction): QueueState
...state,
climbSearchParams: action.payload,
};
case 'INITIAL_QUEUE_DATA':
case 'INITIAL_QUEUE_DATA': {
const newQueue = action.payload.queue;
let newCurrentClimbQueueItem = action.payload.currentClimbQueueItem ?? state.currentClimbQueueItem;

// Validate that current climb is in the queue
// If not, clear it to prevent inconsistent state
if (newCurrentClimbQueueItem && !newQueue.some(item => item.uuid === newCurrentClimbQueueItem?.uuid)) {
newCurrentClimbQueueItem = null;
}

return {
...state,
queue: action.payload.queue,
currentClimbQueueItem: action.payload.currentClimbQueueItem ?? state.currentClimbQueueItem,
queue: newQueue,
currentClimbQueueItem: newCurrentClimbQueueItem,
initialQueueDataReceivedFromPeers: true,
};
}

case 'UPDATE_QUEUE': {
const newQueue = action.payload.queue;
let newCurrentClimbQueueItem = action.payload.currentClimbQueueItem ?? state.currentClimbQueueItem;

// Validate that current climb is in the queue
// If not, clear it to prevent inconsistent state
if (newCurrentClimbQueueItem && !newQueue.some(item => item.uuid === newCurrentClimbQueueItem?.uuid)) {
newCurrentClimbQueueItem = null;
}

case 'UPDATE_QUEUE':
return {
...state,
queue: action.payload.queue,
currentClimbQueueItem: action.payload.currentClimbQueueItem ?? state.currentClimbQueueItem,
queue: newQueue,
currentClimbQueueItem: newCurrentClimbQueueItem,
};
}

case 'ADD_TO_QUEUE':
return {
Expand Down
Loading