diff --git a/.changeset/spotty-meals-glow.md b/.changeset/spotty-meals-glow.md new file mode 100644 index 000000000..39fb18a50 --- /dev/null +++ b/.changeset/spotty-meals-glow.md @@ -0,0 +1,6 @@ +--- +"@ensnode/ensnode-react": minor +"ensadmin": minor +--- + +Enhance useIndexingStatus with in-memory snapshot caching and real-time projection updates for smoother, continuously refreshed indexing status between API fetches diff --git a/apps/ensadmin/src/components/datetime-utils/index.tsx b/apps/ensadmin/src/components/datetime-utils/index.tsx index 9042a0ea3..310a96af5 100644 --- a/apps/ensadmin/src/components/datetime-utils/index.tsx +++ b/apps/ensadmin/src/components/datetime-utils/index.tsx @@ -104,9 +104,18 @@ export function RelativeTime({ const [relativeTime, setRelativeTime] = useState(""); useEffect(() => { - setRelativeTime( - formatRelativeTime(timestamp, enforcePast, includeSeconds, conciseFormatting, relativeTo), - ); + const updateTime = () => { + setRelativeTime( + formatRelativeTime(timestamp, enforcePast, includeSeconds, conciseFormatting, relativeTo), + ); + }; + + updateTime(); + + if (includeSeconds) { + const interval = setInterval(updateTime, 1000); + return () => clearInterval(interval); + } }, [timestamp, conciseFormatting, enforcePast, includeSeconds, relativeTo]); const tooltipTriggerContent = ( diff --git a/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx b/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx index 63a487f53..9e76d8e16 100644 --- a/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx +++ b/apps/ensadmin/src/components/indexing-status/indexing-stats.tsx @@ -33,6 +33,7 @@ import { cn } from "@/lib/utils"; import { BackfillStatus } from "./backfill-status"; import { BlockStats } from "./block-refs"; import { IndexingStatusLoading } from "./indexing-status-loading"; +import { ProjectionInfo } from "./projection-info"; interface IndexingStatsForOmnichainStatusSnapshotProps< OmnichainIndexingStatusSnapshotType extends @@ -323,22 +324,32 @@ export function IndexingStatsForSnapshotFollowing({ */ export function IndexingStatsShell({ omnichainStatus, + realtimeProjection, children, -}: PropsWithChildren<{ omnichainStatus?: OmnichainIndexingStatusId }>) { +}: PropsWithChildren<{ + omnichainStatus?: OmnichainIndexingStatusId; + realtimeProjection?: RealtimeIndexingStatusProjection; +}>) { return ( - - Indexing Status + +
+ Indexing Status + + {omnichainStatus && ( + + {formatOmnichainIndexingStatus(omnichainStatus)} + + )} +
- {omnichainStatus && ( - - {formatOmnichainIndexingStatus(omnichainStatus)} - - )} + {realtimeProjection && }
@@ -422,7 +433,10 @@ export function IndexingStatsForRealtimeStatusProjection({
{maybeIndexingTimeline} - + {indexingStats}
diff --git a/apps/ensadmin/src/components/indexing-status/projection-info.tsx b/apps/ensadmin/src/components/indexing-status/projection-info.tsx new file mode 100644 index 000000000..1e135d65b --- /dev/null +++ b/apps/ensadmin/src/components/indexing-status/projection-info.tsx @@ -0,0 +1,57 @@ +"use client"; + +import { InfoIcon } from "lucide-react"; + +import type { RealtimeIndexingStatusProjection } from "@ensnode/ensnode-sdk"; + +import { RelativeTime } from "@/components/datetime-utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; + +interface ProjectionInfoProps { + realtimeProjection: RealtimeIndexingStatusProjection; +} + +/** + * Displays metadata about the current indexing status projection in a tooltip. + * Shows when the projection was generated, when the snapshot was taken, and worst-case distance. + */ +export function ProjectionInfo({ realtimeProjection }: ProjectionInfoProps) { + const { projectedAt, snapshot, worstCaseDistance } = realtimeProjection; + const { snapshotTime } = snapshot; + + return ( + + + + + +
+
+
+ Worst-Case Distance* +
+
+ {worstCaseDistance !== null ? `${worstCaseDistance} seconds` : "N/A"} +
+
+ +
+ * as of real-time projection generated{" "} + from indexing + status snapshot captured{" "} + . +
+
+
+
+ ); +} diff --git a/packages/ensnode-react/package.json b/packages/ensnode-react/package.json index 99195e3a1..0bd7d019c 100644 --- a/packages/ensnode-react/package.json +++ b/packages/ensnode-react/package.json @@ -57,6 +57,7 @@ "vitest": "catalog:" }, "dependencies": { - "@ensnode/ensnode-sdk": "workspace:*" + "@ensnode/ensnode-sdk": "workspace:*", + "date-fns": "catalog:" } } diff --git a/packages/ensnode-react/src/hooks/index.ts b/packages/ensnode-react/src/hooks/index.ts index ff8fdad62..818053b6e 100644 --- a/packages/ensnode-react/src/hooks/index.ts +++ b/packages/ensnode-react/src/hooks/index.ts @@ -1,6 +1,7 @@ export * from "./useENSNodeConfig"; export * from "./useENSNodeSDKConfig"; export * from "./useIndexingStatus"; +export * from "./useNow"; export * from "./usePrimaryName"; export * from "./usePrimaryNames"; export * from "./useRecords"; diff --git a/packages/ensnode-react/src/hooks/useIndexingStatus.ts b/packages/ensnode-react/src/hooks/useIndexingStatus.ts index c56f3d692..328be35db 100644 --- a/packages/ensnode-react/src/hooks/useIndexingStatus.ts +++ b/packages/ensnode-react/src/hooks/useIndexingStatus.ts @@ -1,15 +1,50 @@ -import { useQuery } from "@tanstack/react-query"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useMemo } from "react"; -import type { IndexingStatusRequest, IndexingStatusResponse } from "@ensnode/ensnode-sdk"; +import { + createRealtimeIndexingStatusProjection, + type IndexingStatusRequest, + type IndexingStatusResponse, + IndexingStatusResponseCodes, +} from "@ensnode/ensnode-sdk"; import type { QueryParameter, WithSDKConfigParameter } from "../types"; import { createIndexingStatusQueryOptions } from "../utils/query"; import { useENSNodeSDKConfig } from "./useENSNodeSDKConfig"; +import { useNow } from "./useNow"; interface UseIndexingStatusParameters extends IndexingStatusRequest, QueryParameter {} +/** + * Hook for fetching and tracking indexing status with client-side projection updates. + * + * Clients often need frequently updated worst-case distance for their logic, + * but calling the API every second would be inefficient. Instead, we fetch a + * snapshot and keep it in memory. We then asynchronously attempt to update it every 10 seconds. + * + * From the most recently cached snapshot, this hook instantly generates new projections — + * entirely in memory. Each projection provides a recalculation of worst-case distance based on: + * • The current time (when the projection was generated) + * • The snapshot's absolute timestamps of recorded indexing progress + * + * This works reliably because indexing progress is virtually always non-decreasing over + * time (virtually never goes backward). Clients can safely assume that a snapshot from a + * few seconds ago is still valid for building new projections. Since snapshots + * exclusively contain absolute timestamps, we can reuse a snapshot across time to continuously compute updated worst-case projections without additional API calls. + * + * **Error Handling:** + * When the indexing status API returns an error response, this hook continues to display + * the last successful snapshot while projecting forward in time. This provides a graceful + * degradation experience - the UI shows slightly stale but still useful data rather than + * breaking completely. + * + * @param parameters - Configuration options + * @param parameters.config - ENSNode SDK configuration (optional, uses context if not provided) + * @param parameters.query - TanStack Query options for customizing query behavior (refetchInterval, enabled, etc.) + * @returns TanStack Query result containing a new indexing status projection based on the current time + */ export function useIndexingStatus( parameters: WithSDKConfigParameter & UseIndexingStatusParameters = {}, ) { @@ -21,9 +56,51 @@ export function useIndexingStatus( const options = { ...queryOptions, refetchInterval: 10 * 1000, // 10 seconds - indexing status changes frequently + placeholderData: keepPreviousData, // Keep showing previous data during refetch and on error ...query, enabled: query.enabled ?? queryOptions.enabled, }; - return useQuery(options); + const queryResult = useQuery(options); + + // Extract the current snapshot from the query result. + // Thanks to placeholderData: keepPreviousData, this will continue showing + // the last successful snapshot even when subsequent fetches fail. + // Note: queryFn now throws on error responses, so data will only contain valid responses + + // debug + const currentSnapshot = + queryResult.data?.responseCode === IndexingStatusResponseCodes.Ok + ? queryResult.data.realtimeProjection.snapshot + : null; + + // / worstCaseDistance is measured in seconds + + // Get current timestamp that updates every second. + // Each component instance gets its own timestamp + const projectedAt = useNow(1000); + + // Generate projection from cached snapshot using the synchronized timestamp. + // useMemo ensures we only create a new projection object when values actually change, + // maintaining referential equality for unchanged data (prevents unnecessary re-renders). + const projectedData = useMemo(() => { + if (!currentSnapshot) return null; + + const realtimeProjection = createRealtimeIndexingStatusProjection(currentSnapshot, projectedAt); + + return { + // debugging + responseCode: "ok" as const, + realtimeProjection, + } satisfies IndexingStatusResponse; + }, [currentSnapshot, projectedAt]); + + if (projectedData) { + return { + ...queryResult, + data: projectedData, + }; + } + + return queryResult; } diff --git a/packages/ensnode-react/src/hooks/useNow.ts b/packages/ensnode-react/src/hooks/useNow.ts new file mode 100644 index 000000000..2458c0f36 --- /dev/null +++ b/packages/ensnode-react/src/hooks/useNow.ts @@ -0,0 +1,31 @@ +import { getUnixTime } from "date-fns"; +import { useEffect, useState } from "react"; + +/** + * Hook that returns the current Unix timestamp, updated at a specified interval. + * + * @param refreshRate - How often to update the timestamp in milliseconds (default: 1000ms) + * @returns Current Unix timestamp that updates every refreshRate milliseconds + * + * @example + * ```tsx + * // Updates every second + * const now = useNow(1000); + * + * // Updates every 5 seconds + * const now = useNow(5000); + * ``` + */ +export function useNow(refreshRate = 1000): number { + const [now, setNow] = useState(() => getUnixTime(new Date())); + + useEffect(() => { + const interval = setInterval(() => { + setNow(getUnixTime(new Date())); + }, refreshRate); + + return () => clearInterval(interval); + }, [refreshRate]); + + return now; +} diff --git a/packages/ensnode-react/src/utils/query.ts b/packages/ensnode-react/src/utils/query.ts index 519800be9..415f2eb00 100644 --- a/packages/ensnode-react/src/utils/query.ts +++ b/packages/ensnode-react/src/utils/query.ts @@ -4,6 +4,7 @@ import type { UndefinedInitialDataOptions } from "@tanstack/react-query"; import { ENSNodeClient, + IndexingStatusResponseCodes, type RegistrarActionsRequest, type ResolvePrimaryNameRequest, type ResolvePrimaryNamesRequest, @@ -128,6 +129,9 @@ export function createConfigQueryOptions(config: ENSNodeSDKConfig) { /** * Creates query options for ENSNode Indexing Status API + * + * Note: This query throws when the response indicates an error status, + * ensuring React Query treats it as a failed fetch and maintains previous data. */ export function createIndexingStatusQueryOptions(config: ENSNodeSDKConfig) { return { @@ -135,7 +139,14 @@ export function createIndexingStatusQueryOptions(config: ENSNodeSDKConfig) { queryKey: queryKeys.indexingStatus(config.client.url.href), queryFn: async () => { const client = new ENSNodeClient(config.client); - return client.indexingStatus(); + const response = await client.indexingStatus(); + + // debug: use placeholderData to keep showing the last valid data + if (response.responseCode === IndexingStatusResponseCodes.Error) { + throw new Error("Indexing status is currently unavailable"); + } + + return response; }, }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 96dbf6864..947a067a3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -697,6 +697,9 @@ importers: '@ensnode/ensnode-sdk': specifier: workspace:* version: link:../ensnode-sdk + date-fns: + specifier: 'catalog:' + version: 4.1.0 react: specifier: ^18.0.0 || ^19.0.0 version: 19.2.0