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
64 changes: 38 additions & 26 deletions src/app/components/PriceFeedCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,17 @@ const PriceFeedCard: React.FC<PriceFeedCardProps> = ({

// Granular context subscriptions — each hook only re-renders this component
// when its specific slice changes, not on every unrelated socket event.
const { isConnected, error: wsError } = useSocketConnection();
const {
isConnected,
error: wsError,
isPageVisible,
} = useSocketConnection();
const { lastUpdate: wsUpdate } = useSocketData();

const load = useCallback(
async (manual = false) => {
if (!manual && !isPageVisible) return;

if (manual) {
setIsRefreshing(true);
start();
Expand All @@ -135,7 +141,7 @@ const PriceFeedCard: React.FC<PriceFeedCardProps> = ({
if (manual) done();
}
},
[start, done],
[done, isPageVisible, setError, start],
);

// Merge WebSocket delta updates into local state.
Expand Down Expand Up @@ -164,20 +170,30 @@ const PriceFeedCard: React.FC<PriceFeedCardProps> = ({
setLastRefresh(new Date());
setLoading(false);
setError(null);
}, [wsUpdate, enableWebSocket]); // `data` intentionally omitted — accessed via functional updater
}, [wsUpdate, enableWebSocket, isPageVisible, setError]); // `data` intentionally omitted — accessed via functional updater

// Handle WebSocket errors
useEffect(() => {
if (wsError && enableWebSocket) {
// eslint-disable-next-line react-hooks/set-state-in-effect
setError(`WebSocket error: ${wsError}`);
}
}, [wsError, enableWebSocket]);
}, [wsError, enableWebSocket, setError]);

// Initial fetch + fallback polling (only when WebSocket is disabled or disconnected)
const pollingActive = isPageVisible && (!enableWebSocket || !isConnected);
const isPaused = !isPageVisible;
const isLive = isPageVisible && enableWebSocket && isConnected;

useEffect(() => {
if (pollingActive) load();
if (!pollingActive) return;

const timeoutId = window.setTimeout(() => {
void load();
}, 0);

return () => {
window.clearTimeout(timeoutId);
};
}, [pollingActive, load]);

useRAFInterval(load, refreshInterval, pollingActive);
Expand All @@ -196,22 +212,6 @@ const PriceFeedCard: React.FC<PriceFeedCardProps> = ({
: "shadow-[0_0_18px_rgba(244,63,94,0.18)]";

const priceColor = isUp ? "text-emerald-400" : "text-rose-400";
const [isPageVisible, setIsPageVisible] = useState(() => {
if (typeof document === "undefined") return true;
return document.visibilityState === "visible";
});

useEffect(() => {
const handleVisibilityChange = () => {
setIsPageVisible(document.visibilityState === "visible");
};

document.addEventListener("visibilitychange", handleVisibilityChange);

return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);
return (
<div
style={{ contain: "paint layout" }}
Expand Down Expand Up @@ -241,28 +241,40 @@ const PriceFeedCard: React.FC<PriceFeedCardProps> = ({
<div className="flex items-center gap-2">
<span
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[10px] font-semibold ${
enableWebSocket && isConnected
isPaused
? "border-slate-500/20 bg-slate-500/10 text-slate-400"
: isLive
? "border-[#39FF14]/20 bg-[#39FF14]/10 text-[#39FF14]"
: "border-yellow-500/20 bg-yellow-500/10 text-yellow-500"
}`}
>
<span className="relative flex h-1.5 w-1.5">
<span
className={`absolute inline-flex h-full w-full rounded-full ${
enableWebSocket && isConnected
isLive
? "animate-ping bg-[#39FF14] opacity-60"
: isPaused
? "bg-slate-500 opacity-60"
: "bg-yellow-500 opacity-60"
}`}
/>
<span
className={`relative inline-flex h-1.5 w-1.5 rounded-full ${
enableWebSocket && isConnected
isLive
? "bg-[#39FF14]"
: isPaused
? "bg-slate-500"
: "bg-yellow-500"
}`}
/>
</span>
{enableWebSocket ? (isConnected ? "WS LIVE" : "WS OFF") : "POLLING"}
{isPaused
? "PAUSED"
: enableWebSocket
? isConnected
? "WS LIVE"
: "WS OFF"
: "POLLING"}
</span>

<button
Expand Down
6 changes: 4 additions & 2 deletions src/app/components/providers/SocketProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { PriceData } from '@/types'
interface SocketConnectionContextType {
isConnected: boolean
error: string | null
isPageVisible: boolean
reconnectAttempts: number
}

Expand Down Expand Up @@ -52,6 +53,7 @@ export function SocketProvider({ children, options }: SocketProviderProps) {
isConnected,
lastUpdate,
error,
isPageVisible,
reconnectAttempts,
subscribeToAsset,
unsubscribeFromAsset,
Expand All @@ -63,8 +65,8 @@ export function SocketProvider({ children, options }: SocketProviderProps) {
// `dataValue` object changes; `connectionValue` and `actionsValue` keep the
// same reference, so their consumers are skipped by React's reconciler.
const connectionValue = useMemo<SocketConnectionContextType>(
() => ({ isConnected, error, reconnectAttempts }),
[isConnected, error, reconnectAttempts],
() => ({ isConnected, error, isPageVisible, reconnectAttempts }),
[isConnected, error, isPageVisible, reconnectAttempts],
)

const dataValue = useMemo<SocketDataContextType>(
Expand Down
39 changes: 39 additions & 0 deletions src/app/hooks/usePageVisibility.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
"use client";

import { useEffect, useState } from "react";

function readPageVisibility(): boolean {
if (
typeof document === "undefined" ||
typeof document.visibilityState === "undefined"
) {
return true;
}

return document.visibilityState !== "hidden";
}

export function usePageVisibility(): boolean {
const [isPageVisible, setIsPageVisible] = useState(readPageVisibility);

useEffect(() => {
if (
typeof document === "undefined" ||
typeof document.visibilityState === "undefined"
) {
return;
}

const handleVisibilityChange = () => {
setIsPageVisible(readPageVisibility());
};

document.addEventListener("visibilitychange", handleVisibilityChange);

return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, []);

return isPageVisible;
}
94 changes: 50 additions & 44 deletions src/app/hooks/useSocket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useEffect, useRef, useState, useCallback } from "react";
import { PriceData } from "@/types";
import { useErrorTimeout } from "./useErrorTimeout";
import { usePageVisibility } from "./usePageVisibility";

interface SocketMessage {
type: "price_update" | "delta_update";
Expand All @@ -28,6 +29,7 @@ interface UseSocketReturn {
isConnected: boolean;
lastUpdate: PriceData | null;
error: string | null;
isPageVisible: boolean;
reconnectAttempts: number;
subscribeToAsset: (assetId: string) => void;
unsubscribeFromAsset: (assetId: string) => void;
Expand All @@ -46,6 +48,7 @@ export function useSocket(options: UseSocketOptions = {}): UseSocketReturn {
const [isConnected, setIsConnected] = useState(false);
const [lastUpdate, setLastUpdate] = useState<PriceData | null>(null);
const { error, setError } = useErrorTimeout({ timeoutMs: errorTimeoutMs });
const isPageVisible = usePageVisibility();
const [reconnectAttempts, setReconnectAttempts] = useState(0);

const wsRef = useRef<WebSocket | null>(null);
Expand All @@ -54,7 +57,9 @@ export function useSocket(options: UseSocketOptions = {}): UseSocketReturn {
null,
);
const manuallyDisconnectedRef = useRef(false);
const pageVisibleRef = useRef(true);
const pageVisibleRef = useRef(
typeof document === "undefined" || document.visibilityState !== "hidden",
);

// Refs keep options fresh inside callbacks without triggering re-renders or
// causing `connect` to be recreated on every tick.
Expand All @@ -70,21 +75,25 @@ export function useSocket(options: UseSocketOptions = {}): UseSocketReturn {
reconnectIntervalRef.current = reconnectInterval;
}, [maxReconnectAttempts, reconnectInterval]);

// `connect` has an empty dependency array because every value it needs is
// accessed through a ref. This breaks the cycle where a WS message would
// update `lastUpdate` → recreate `connect` → effect fires → socket torn down.
// `connect` only depends on stable callbacks and refs. This breaks the cycle
// where a WS message would update `lastUpdate` → recreate `connect` → effect
// fires → socket torn down.
const connect = useCallback(function doConnect() {
if (
typeof document !== "undefined" &&
document.visibilityState === "hidden"
) {
pageVisibleRef.current = false;
return;
}

pageVisibleRef.current = true;
manuallyDisconnectedRef.current = false;

if (wsRef.current?.readyState === WebSocket.OPEN) {
if (
wsRef.current?.readyState === WebSocket.OPEN ||
wsRef.current?.readyState === WebSocket.CONNECTING
) {
return;
}
try {
Expand Down Expand Up @@ -162,7 +171,30 @@ export function useSocket(options: UseSocketOptions = {}): UseSocketReturn {
setError("Failed to establish WebSocket connection");
console.error("Connection error:", err);
}
}, []); // ← intentionally empty; all mutable values go through refs
}, [setError]);

const pauseLiveUpdates = useCallback((reason = "Page hidden") => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}

if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: "unsubscribe",
assetIds: Array.from(subscribedAssetsRef.current),
}),
);
}

if (wsRef.current) {
wsRef.current.close(1000, reason);
wsRef.current = null;
}

setIsConnected(false);
}, []);

const disconnect = useCallback(() => {
manuallyDisconnectedRef.current = true;
Expand Down Expand Up @@ -235,51 +267,25 @@ export function useSocket(options: UseSocketOptions = {}): UseSocketReturn {
};
}, []);
useEffect(() => {
const handleVisibilityChange = () => {
const isVisible = document.visibilityState === "visible";
pageVisibleRef.current = isVisible;

if (!isVisible) {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
pageVisibleRef.current = isPageVisible;

if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(
JSON.stringify({
type: "unsubscribe",
assetIds: Array.from(subscribedAssetsRef.current),
}),
);
}

if (wsRef.current) {
wsRef.current.close(1000, "Page hidden");
wsRef.current = null;
}

setIsConnected(false);
return;
}

if (!manuallyDisconnectedRef.current) {
reconnectAttemptsRef.current = 0;
setReconnectAttempts(0);
connect();
}
};
if (!isPageVisible) {
pauseLiveUpdates();
return;
}

document.addEventListener("visibilitychange", handleVisibilityChange);
if (!manuallyDisconnectedRef.current) {
reconnectAttemptsRef.current = 0;
setReconnectAttempts(0);
connect();
}
}, [isPageVisible, connect, pauseLiveUpdates]);

return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [connect]);
return {
isConnected,
lastUpdate,
error,
isPageVisible,
reconnectAttempts,
subscribeToAsset,
unsubscribeFromAsset,
Expand Down