From bdfc634c54bd3ccbd6600490f0f2e5c61913dbf3 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Dec 2025 12:00:42 +0000 Subject: [PATCH] feat: add dependency stabilization hooks - Create src/hooks/useStable.ts with: - useStableValue() - stabilizes any value using JSON.stringify - useStableArray() - stabilizes string arrays (uses JSON.stringify for safety, handles arrays with commas in elements) - useStableFilters() - specialized for Nostr filters - Update timeline hooks to use stabilization: - useTimeline.ts - use useStableFilters for filter dependencies - useReqTimeline.ts - use useStableValue for filter dependencies - useLiveTimeline.ts - use useStableArray for relay dependencies Prevents unnecessary re-renders and subscription restarts when filter/relay objects are recreated with the same content. --- src/hooks/useLiveTimeline.ts | 12 +++---- src/hooks/useReqTimeline.ts | 10 +++--- src/hooks/useStable.ts | 61 ++++++++++++++++++++++++++++++++++++ src/hooks/useTimeline.ts | 12 +++---- 4 files changed, 75 insertions(+), 20 deletions(-) create mode 100644 src/hooks/useStable.ts diff --git a/src/hooks/useLiveTimeline.ts b/src/hooks/useLiveTimeline.ts index d5664c5..a641ce5 100644 --- a/src/hooks/useLiveTimeline.ts +++ b/src/hooks/useLiveTimeline.ts @@ -1,8 +1,9 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect } from "react"; import pool from "@/services/relay-pool"; import type { NostrEvent, Filter } from "nostr-tools"; import { useEventStore, useObservableMemo } from "applesauce-react/hooks"; import { isNostrEvent } from "@/lib/type-guards"; +import { useStableValue, useStableArray } from "./useStable"; interface UseLiveTimelineOptions { limit?: number; @@ -38,12 +39,9 @@ export function useLiveTimeline( const [error, setError] = useState(null); const [eoseReceived, setEoseReceived] = useState(false); - // Stabilize filters and relays for dependency array - // Using JSON.stringify and .join() for deep comparison - this is intentional - // eslint-disable-next-line react-hooks/exhaustive-deps - const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const stableRelays = useMemo(() => relays, [relays.join(",")]); + // Stabilize filters and relays to prevent unnecessary re-renders + const stableFilters = useStableValue(filters); + const stableRelays = useStableArray(relays); // 1. Subscription Effect - Fetch data and feed EventStore useEffect(() => { diff --git a/src/hooks/useReqTimeline.ts b/src/hooks/useReqTimeline.ts index ef11386..335a2ec 100644 --- a/src/hooks/useReqTimeline.ts +++ b/src/hooks/useReqTimeline.ts @@ -3,6 +3,7 @@ import pool from "@/services/relay-pool"; import type { NostrEvent, Filter } from "nostr-tools"; import { useEventStore } from "applesauce-react/hooks"; import { isNostrEvent } from "@/lib/type-guards"; +import { useStableValue, useStableArray } from "./useStable"; interface UseReqTimelineOptions { limit?: number; @@ -47,12 +48,9 @@ export function useReqTimeline( ); }, [eventsMap]); - // Stabilize filters and relays for dependency array - // Using JSON.stringify and .join() for deep comparison - this is intentional - // eslint-disable-next-line react-hooks/exhaustive-deps - const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const stableRelays = useMemo(() => relays, [relays.join(",")]); + // Stabilize filters and relays to prevent unnecessary re-renders + const stableFilters = useStableValue(filters); + const stableRelays = useStableArray(relays); useEffect(() => { if (relays.length === 0) { diff --git a/src/hooks/useStable.ts b/src/hooks/useStable.ts new file mode 100644 index 0000000..1ddb430 --- /dev/null +++ b/src/hooks/useStable.ts @@ -0,0 +1,61 @@ +import { useMemo } from "react"; + +/** + * Stabilize a value for use in dependency arrays + * + * React's useEffect/useMemo compare dependencies by reference. + * For objects/arrays that are recreated each render but have the same content, + * this causes unnecessary re-runs. This hook memoizes the value based on + * a serialized representation. + * + * @param value - The value to stabilize + * @param serialize - Optional custom serializer (defaults to JSON.stringify) + * @returns The memoized value + * + * @example + * ```typescript + * // Instead of: useMemo(() => filters, [JSON.stringify(filters)]) + * const stableFilters = useStableValue(filters); + * ``` + */ +export function useStableValue( + value: T, + serialize?: (v: T) => string +): T { + const serialized = serialize?.(value) ?? JSON.stringify(value); + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => value, [serialized]); +} + +/** + * Stabilize a string array for use in dependency arrays + * + * Uses JSON.stringify for safe serialization (handles arrays with commas in elements). + * + * @param arr - The array to stabilize + * @returns The memoized array + * + * @example + * ```typescript + * // Instead of: useMemo(() => relays, [JSON.stringify(relays)]) + * const stableRelays = useStableArray(relays); + * ``` + */ +export function useStableArray(arr: T[]): T[] { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => arr, [JSON.stringify(arr)]); +} + +/** + * Stabilize a Nostr filter or array of filters + * + * Specialized stabilizer for Nostr filters which are commonly + * recreated on each render. + * + * @param filters - Single filter or array of filters + * @returns The memoized filter(s) + */ +export function useStableFilters(filters: T): T { + // eslint-disable-next-line react-hooks/exhaustive-deps + return useMemo(() => filters, [JSON.stringify(filters)]); +} diff --git a/src/hooks/useTimeline.ts b/src/hooks/useTimeline.ts index 672684d..63465f2 100644 --- a/src/hooks/useTimeline.ts +++ b/src/hooks/useTimeline.ts @@ -1,9 +1,10 @@ -import { useState, useEffect, useMemo } from "react"; +import { useState, useEffect } from "react"; import type { NostrEvent, Filter } from "nostr-tools"; import { useEventStore, useObservableMemo } from "applesauce-react/hooks"; import { createTimelineLoader } from "@/services/loaders"; import pool from "@/services/relay-pool"; import { AGGREGATOR_RELAYS } from "@/services/loaders"; +import { useStableValue, useStableArray } from "./useStable"; interface UseTimelineOptions { limit?: number; @@ -35,12 +36,9 @@ export function useTimeline( const [loading, setLoading] = useState(false); const [error, setError] = useState(null); - // Stabilize filters and relays for dependency array - // Using JSON.stringify and .join() for deep comparison - this is intentional - // eslint-disable-next-line react-hooks/exhaustive-deps - const stableFilters = useMemo(() => filters, [JSON.stringify(filters)]); - // eslint-disable-next-line react-hooks/exhaustive-deps - const stableRelays = useMemo(() => relays, [relays.join(",")]); + // Stabilize filters and relays to prevent unnecessary re-renders + const stableFilters = useStableValue(filters); + const stableRelays = useStableArray(relays); // Load events into store useEffect(() => {