372 lines
11 KiB
JavaScript
372 lines
11 KiB
JavaScript
import { useRef, useEffect, useMemo, useState } from "react";
|
|
import { useQuery } from "@apollo/client";
|
|
import * as Sentry from "@sentry/react-native";
|
|
import { useNetworkState } from "~/stores";
|
|
import useShallowMemo from "./useShallowMemo";
|
|
|
|
// Constants for retry configuration
|
|
const MAX_RETRIES = 5;
|
|
const INITIAL_BACKOFF_MS = 1000; // 1 second
|
|
const MAX_BACKOFF_MS = 30000; // 30 seconds
|
|
|
|
/**
|
|
* Hook that queries for items with custom sorting (e.g., acknowledged first, then by newest)
|
|
* while still using ID-based cursor for subscriptions to new items.
|
|
*
|
|
* This pattern handles cases where UI display order differs from subscription cursor order.
|
|
* Items in the UI can be sorted by multiple criteria (primary: acknowledged, secondary: id),
|
|
* but the subscription still uses a consistent, incrementing ID field for the cursor.
|
|
*/
|
|
export default function useLatestWithSubscription(
|
|
initialQuery,
|
|
subscription,
|
|
{
|
|
cursorVar = "cursor",
|
|
cursorKey = "id",
|
|
uniqKey = "id",
|
|
variables: paramVariables = {},
|
|
skip = false,
|
|
subscriptionKey = "default",
|
|
context = {},
|
|
shouldIncludeItem = () => true,
|
|
maxRetries = MAX_RETRIES,
|
|
...queryParams
|
|
} = {},
|
|
) {
|
|
const variables = useShallowMemo(() => paramVariables, paramVariables);
|
|
|
|
const { wsClosedDate } = useNetworkState(["wsClosedDate"]);
|
|
|
|
// State to force re-render and retry subscription
|
|
const [retryTrigger, setRetryTrigger] = useState(0);
|
|
|
|
const variableHashRef = useRef(JSON.stringify(variables));
|
|
const highestIdRef = useRef(null);
|
|
const initialSetupDoneRef = useRef(false);
|
|
const wasLoadingRef = useRef(true);
|
|
const retryCountRef = useRef(0);
|
|
const subscriptionErrorRef = useRef(null);
|
|
const timeoutIdRef = useRef(null);
|
|
|
|
useEffect(() => {
|
|
const currentVarsHash = JSON.stringify(variables);
|
|
if (currentVarsHash !== variableHashRef.current) {
|
|
console.log(
|
|
`[${subscriptionKey}] Variables changed, resetting subscription setup`,
|
|
);
|
|
highestIdRef.current = null;
|
|
variableHashRef.current = currentVarsHash;
|
|
initialSetupDoneRef.current = false;
|
|
wasLoadingRef.current = true;
|
|
}
|
|
}, [variables, subscriptionKey]);
|
|
|
|
const {
|
|
data: queryData,
|
|
loading,
|
|
error,
|
|
subscribeToMore,
|
|
} = useQuery(initialQuery, {
|
|
...queryParams,
|
|
variables,
|
|
fetchPolicy: queryParams.fetchPolicy || "cache-and-network",
|
|
skip,
|
|
context: {
|
|
...context,
|
|
subscriptionKey,
|
|
},
|
|
});
|
|
|
|
// Extract highest ID from the query results (which are in DESC order)
|
|
useEffect(() => {
|
|
if (!queryData) return;
|
|
|
|
const queryRootKey = Object.keys(queryData)[0];
|
|
if (!queryRootKey) return;
|
|
|
|
const items = queryData[queryRootKey] || [];
|
|
// Filter out optimistic items
|
|
const nonOptimisticItems = items.filter((item) => !item.isOptimistic);
|
|
|
|
if (nonOptimisticItems.length > 0) {
|
|
// When sorted by descending order, the first item has the highest ID
|
|
const highestItem = nonOptimisticItems[0];
|
|
const highestId = highestItem[cursorKey];
|
|
|
|
if (
|
|
highestId !== undefined &&
|
|
(highestIdRef.current === null || highestId > highestIdRef.current)
|
|
) {
|
|
highestIdRef.current = highestId;
|
|
console.log(
|
|
`[${subscriptionKey}] Updated subscription cursor to highest ID:`,
|
|
highestId,
|
|
);
|
|
}
|
|
} else {
|
|
// Handle empty results case - initialize with 0 to allow subscription for first item
|
|
if (highestIdRef.current === null) {
|
|
highestIdRef.current = 0;
|
|
console.log(
|
|
`[${subscriptionKey}] No initial items, setting subscription cursor to:`,
|
|
0,
|
|
);
|
|
}
|
|
}
|
|
}, [queryData, cursorKey, subscriptionKey]);
|
|
|
|
// Track loading changes
|
|
useEffect(() => {
|
|
if (wasLoadingRef.current && !loading) {
|
|
wasLoadingRef.current = false;
|
|
}
|
|
}, [loading]);
|
|
|
|
// Reset retry counter when variables change
|
|
useEffect(() => {
|
|
retryCountRef.current = 0;
|
|
subscriptionErrorRef.current = null;
|
|
}, [variables]);
|
|
|
|
// Set up the subscription once initial query is done, or on WS reconnect, etc.
|
|
useEffect(() => {
|
|
if (skip) return; // If skipping, do nothing
|
|
if (!subscribeToMore) return;
|
|
if (highestIdRef.current === null) return; // Wait until we have the highest ID
|
|
|
|
// Check if max retries reached and we have an error
|
|
if (retryCountRef.current >= maxRetries && subscriptionErrorRef.current) {
|
|
console.error(
|
|
`[${subscriptionKey}] Max retries (${maxRetries}) reached. Stopping subscription attempts.`,
|
|
subscriptionErrorRef.current,
|
|
);
|
|
|
|
// Report to Sentry when max retries are reached
|
|
try {
|
|
Sentry.captureException(subscriptionErrorRef.current, {
|
|
tags: {
|
|
subscriptionKey,
|
|
maxRetries: String(maxRetries),
|
|
context: "useLatestWithSubscription",
|
|
},
|
|
extra: {
|
|
variablesHash: variableHashRef.current,
|
|
highestId: highestIdRef.current,
|
|
},
|
|
});
|
|
} catch (sentryError) {
|
|
console.error("Failed to report to Sentry:", sentryError);
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
// Wait for:
|
|
// - either initial setup not done yet
|
|
// - or a new wsClosedDate (WS reconnect)
|
|
// - or a retry trigger
|
|
if (initialSetupDoneRef.current && !wsClosedDate && retryTrigger === 0) {
|
|
return;
|
|
}
|
|
|
|
// Also wait until the query stops loading
|
|
if (!initialSetupDoneRef.current && wasLoadingRef.current) return;
|
|
|
|
// Clean up any existing timeout
|
|
if (timeoutIdRef.current) {
|
|
clearTimeout(timeoutIdRef.current);
|
|
timeoutIdRef.current = null;
|
|
}
|
|
|
|
// Calculate backoff delay if this is a retry
|
|
const backoffDelay =
|
|
retryCountRef.current > 0
|
|
? Math.min(
|
|
INITIAL_BACKOFF_MS * Math.pow(2, retryCountRef.current - 1),
|
|
MAX_BACKOFF_MS,
|
|
)
|
|
: 0;
|
|
|
|
const retryMessage =
|
|
retryCountRef.current > 0
|
|
? ` Retry attempt ${retryCountRef.current}/${maxRetries} after ${backoffDelay}ms delay`
|
|
: "";
|
|
|
|
console.log(
|
|
`[${subscriptionKey}] Setting up subscription${retryMessage} with highestId:`,
|
|
highestIdRef.current,
|
|
);
|
|
|
|
// Use timeout for backoff
|
|
timeoutIdRef.current = setTimeout(() => {
|
|
initialSetupDoneRef.current = true;
|
|
|
|
try {
|
|
const unsubscribe = subscribeToMore({
|
|
document: subscription,
|
|
variables: {
|
|
...variables,
|
|
[cursorVar]: highestIdRef.current,
|
|
},
|
|
context: {
|
|
...context,
|
|
subscriptionKey,
|
|
},
|
|
onError: (error) => {
|
|
// Store the error
|
|
subscriptionErrorRef.current = error;
|
|
|
|
// Increment retry counter but don't exceed maxRetries
|
|
retryCountRef.current = Math.min(
|
|
retryCountRef.current + 1,
|
|
maxRetries,
|
|
);
|
|
|
|
console.error(
|
|
`[${subscriptionKey}] Subscription error (attempt ${retryCountRef.current}/${maxRetries}):`,
|
|
error,
|
|
);
|
|
|
|
// If we haven't reached max retries, trigger a retry
|
|
if (retryCountRef.current < maxRetries) {
|
|
// Set a delay before retrying
|
|
setTimeout(() => {
|
|
setRetryTrigger((prev) => prev + 1);
|
|
}, 100); // Small delay to prevent immediate re-execution
|
|
}
|
|
},
|
|
updateQuery: (prev, { subscriptionData }) => {
|
|
// Successfully received data, reset retry counter
|
|
if (subscriptionData.data) {
|
|
retryCountRef.current = 0;
|
|
subscriptionErrorRef.current = null;
|
|
}
|
|
|
|
if (!subscriptionData.data) return prev;
|
|
|
|
const queryRootKey = Object.keys(prev)[0];
|
|
const subscriptionRootKey = Object.keys(subscriptionData.data)[0];
|
|
if (!queryRootKey || !subscriptionRootKey) return prev;
|
|
|
|
const newItems = subscriptionData.data[subscriptionRootKey] || [];
|
|
const existingItems = prev[queryRootKey] || [];
|
|
|
|
// Filter new items
|
|
const filteredNewItems = newItems.filter(
|
|
(item) =>
|
|
shouldIncludeItem(item, context) &&
|
|
!existingItems.some(
|
|
(existing) => existing[uniqKey] === item[uniqKey],
|
|
),
|
|
);
|
|
|
|
if (filteredNewItems.length === 0) return prev;
|
|
|
|
// Update highestId if we received any new items
|
|
filteredNewItems.forEach((item) => {
|
|
const itemId = item[cursorKey];
|
|
if (itemId !== undefined && itemId > highestIdRef.current) {
|
|
highestIdRef.current = itemId;
|
|
}
|
|
});
|
|
|
|
console.log(
|
|
`[${subscriptionKey}] Received ${filteredNewItems.length} new items, updated highestId:`,
|
|
highestIdRef.current,
|
|
);
|
|
|
|
// For latest items pattern, we prepend new items (DESC order in UI)
|
|
return {
|
|
...prev,
|
|
[queryRootKey]: [...filteredNewItems, ...existingItems],
|
|
};
|
|
},
|
|
});
|
|
|
|
// Cleanup on unmount or re-run
|
|
return () => {
|
|
console.log(`[${subscriptionKey}] Cleaning up subscription`);
|
|
if (timeoutIdRef.current) {
|
|
clearTimeout(timeoutIdRef.current);
|
|
timeoutIdRef.current = null;
|
|
}
|
|
unsubscribe();
|
|
};
|
|
} catch (error) {
|
|
// Handle setup errors (like malformed queries)
|
|
console.error(
|
|
`[${subscriptionKey}] Error setting up subscription:`,
|
|
error,
|
|
);
|
|
subscriptionErrorRef.current = error;
|
|
|
|
// Increment retry counter but don't exceed maxRetries
|
|
retryCountRef.current = Math.min(retryCountRef.current + 1, maxRetries);
|
|
|
|
console.error(
|
|
`[${subscriptionKey}] Subscription setup error (attempt ${retryCountRef.current}/${maxRetries}):`,
|
|
error,
|
|
);
|
|
|
|
// If we haven't reached max retries, trigger a retry
|
|
if (retryCountRef.current < maxRetries) {
|
|
setTimeout(() => {
|
|
setRetryTrigger((prev) => prev + 1);
|
|
}, 100);
|
|
} else {
|
|
// Report to Sentry when max retries are reached
|
|
try {
|
|
Sentry.captureException(error, {
|
|
tags: {
|
|
subscriptionKey,
|
|
maxRetries: String(maxRetries),
|
|
context: "useLatestWithSubscription-setup",
|
|
},
|
|
extra: {
|
|
variablesHash: variableHashRef.current,
|
|
highestId: highestIdRef.current,
|
|
},
|
|
});
|
|
} catch (sentryError) {
|
|
console.error("Failed to report to Sentry:", sentryError);
|
|
}
|
|
}
|
|
|
|
return () => {
|
|
if (timeoutIdRef.current) {
|
|
clearTimeout(timeoutIdRef.current);
|
|
timeoutIdRef.current = null;
|
|
}
|
|
};
|
|
}
|
|
}, backoffDelay);
|
|
|
|
// Cleanup function that will run when component unmounts or effect re-runs
|
|
return () => {
|
|
if (timeoutIdRef.current) {
|
|
clearTimeout(timeoutIdRef.current);
|
|
timeoutIdRef.current = null;
|
|
}
|
|
};
|
|
}, [
|
|
skip,
|
|
wsClosedDate,
|
|
subscribeToMore,
|
|
subscription,
|
|
variables,
|
|
cursorVar,
|
|
uniqKey,
|
|
cursorKey,
|
|
subscriptionKey,
|
|
context,
|
|
shouldIncludeItem,
|
|
retryTrigger,
|
|
maxRetries,
|
|
]);
|
|
|
|
return {
|
|
data: queryData,
|
|
loading,
|
|
error,
|
|
};
|
|
}
|