From a3d222efe26ae747098616a75ef437d59548e1ad Mon Sep 17 00:00:00 2001 From: Atila Fassina Date: Wed, 9 Sep 2026 16:57:57 +0200 Subject: [PATCH] feat(appkit-ui): useAnalyticsQuery({ poll }) polling option (poll phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compose the phase-1 uncached refetch with the phase-2 usePoll scheduler behind a new poll option — the one behavior-adding change in this chain. useAnalyticsQuery(key, params, { poll }) accepts `true | { intervalMs, immediate, backoff, maxConsecutiveErrors, onPoll }` and returns the existing fields plus a typed `poll` control/telemetry object (paused, pause, resume, restart, refetch, attempts, errors, skipped, consecutiveErrors, lastLatencyMs, latency:{p50,p95}), present only in poll mode. When poll is set: each tick drives store.refetch(cacheKey) for genuine uncached re-execution (skipCache), retain(autoStart:false) makes the scheduler the sole trigger, a param change is consumed on the next tick (never self-fired), and a cacheKeyRef guard ensures a late completion from an old key cannot mutate the current snapshot. data stays latest-only; loading toggles per tick; warehouseStatus still surfaces on cold polls. Non-poll behavior is unchanged. usePoll stays internal (not in any barrel); public poll types live in types.ts. xavier loop: iteration 4 (phase 3/4) Co-authored-by: Isaac Signed-off-by: Atila Fassina --- .../__tests__/use-analytics-query.test.ts | 470 ++++++++++++++++++ packages/appkit-ui/src/react/hooks/index.ts | 2 + packages/appkit-ui/src/react/hooks/types.ts | 134 +++++ .../src/react/hooks/use-analytics-query.ts | 93 +++- 4 files changed, 697 insertions(+), 2 deletions(-) diff --git a/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts b/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts index e6697eaa5..4a59027ae 100644 --- a/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts +++ b/packages/appkit-ui/src/react/hooks/__tests__/use-analytics-query.test.ts @@ -573,4 +573,474 @@ describe("useAnalyticsQuery", () => { expect(mockConnectSSE).toHaveBeenCalledTimes(1); }); }); + + describe("polling", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runOnlyPendingTimers(); + vi.useRealTimers(); + }); + + test("poll: true enables polling with defaults", async () => { + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { poll: true }), + ); + + // Poll is enabled, so result should have the poll field. + expect(result.current.poll).toBeDefined(); + expect(result.current.poll?.paused).toBe(false); + expect(result.current.poll?.attempts).toBe(0); + }); + + test("poll option enables polling with custom config", async () => { + const onPoll = vi.fn(); + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { + poll: { + intervalMs: 500, + immediate: true, + onPoll, + }, + }), + ); + + // Poll is enabled. + expect(result.current.poll).toBeDefined(); + expect(result.current.poll?.attempts).toBe(0); + + // Fire the immediate tick. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + + // Should have made one request (from immediate). + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + // Complete the request so onPoll fires. + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [] }), + }); + }); + + expect(result.current.poll?.attempts).toBe(1); + expect(onPoll).toHaveBeenCalled(); + }, 15000); + + test("poll forces uncached execution (skipCache in request payload)", async () => { + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { poll: { immediate: false } }), + ); + + expect(mockConnectSSE).not.toHaveBeenCalled(); + + // Resume polling to start. + await act(async () => { + result.current.poll?.resume(); + await vi.advanceTimersByTimeAsync(0); + }); + + // Should have made a request. + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + // Extract the request payload from the connectSSE call. + const payload = lastConnectArgs.payload; + const parsed = JSON.parse(payload); + expect(parsed.skipCache).toBe(true); + }); + + test("autoStart is forced to false when polling is enabled", async () => { + // When poll is enabled, autoStart should be false regardless of the option. + // This means no request fires until the scheduler ticks. + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { + poll: { immediate: false }, + autoStart: true, // This should be ignored. + }), + ); + + // Should not have fired any request (autoStart forced to false). + expect(mockConnectSSE).not.toHaveBeenCalled(); + + // Manual resume should start ticking. + await act(async () => { + result.current.poll?.resume(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + }); + + test("poll refetch triggers an out-of-band re-execution", async () => { + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { poll: { immediate: false } }), + ); + + await act(async () => { + result.current.poll?.refetch(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + // Refetch again (out-of-band, ignores interval). + await act(async () => { + result.current.poll?.refetch(); + await vi.advanceTimersByTimeAsync(0); + }); + + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + }); + + test("onPoll fires on each settle with the latest result", async () => { + const onPoll = vi.fn(); + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { + poll: { immediate: false, onPoll }, + }), + ); + + await act(async () => { + result.current.poll?.resume(); + await vi.advanceTimersByTimeAsync(0); + }); + + // First settle. + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ id: 1 }] }), + }); + }); + + expect(onPoll).toHaveBeenCalledWith( + expect.objectContaining({ + data: [{ id: 1 }], + loading: false, + error: null, + }), + ); + + onPoll.mockClear(); + + // Advance to next poll tick. + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + }); + + // Second settle (should call onPoll again). + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ id: 2 }] }), + }); + }); + + expect(onPoll).toHaveBeenCalledWith( + expect.objectContaining({ + data: [{ id: 2 }], + loading: false, + }), + ); + }, 15000); + + test("parameter change is picked up on NEXT poll tick (not self-fired)", async () => { + const { result, rerender } = renderHook( + ({ limit }: { limit: number }) => + useAnalyticsQuery( + "q", + { limit }, + { + poll: { immediate: true, intervalMs: 500 }, + }, + ), + { initialProps: { limit: 10 } }, + ); + + // Immediate tick should fire once. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + // Complete the first request. + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ id: 1 }] }), + }); + }); + + // Change params mid-flight (creates new cache key). + rerender({ limit: 20 }); + + // Should NOT fire immediately (new key is not auto-started). + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + // Advance to the next poll tick (500ms). + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + // Should now fire with the new params. + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + const secondPayload = lastConnectArgs.payload; + const parsed = JSON.parse(secondPayload); + expect(parsed.parameters).toEqual({ limit: 20 }); + }); + + test("late completion from old key does not mutate current snapshot", async () => { + let firstConnectArgs: any = null; + let secondConnectArgs: any = null; + let thirdConnectArgs: any = null; + + const { result, rerender } = renderHook( + ({ limit }: { limit: number }) => + useAnalyticsQuery( + "q", + { limit }, + { + poll: { immediate: true, intervalMs: 500 }, + }, + ), + { initialProps: { limit: 10 } }, + ); + + // First request starts. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + firstConnectArgs = { ...lastConnectArgs }; + expect(mockConnectSSE).toHaveBeenCalledTimes(1); + + // Complete the first request. + await act(async () => { + await firstConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ id: 1 }] }), + }); + }); + expect(result.current.data).toEqual([{ id: 1 }]); + + // Advance to next tick. + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + + // Second request starts (same params, but new poll tick). + secondConnectArgs = { ...lastConnectArgs }; + expect(mockConnectSSE).toHaveBeenCalledTimes(2); + + // Complete the second request. + await act(async () => { + await secondConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ id: 2 }] }), + }); + }); + expect(result.current.data).toEqual([{ id: 2 }]); + + // Change params to new key. + rerender({ limit: 20 }); + + // A poll tick fires immediately for the new key (via cacheKeyRef). + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + thirdConnectArgs = { ...lastConnectArgs }; + + // The old request's late completion arrives (after params changed). + // This should NOT affect the current snapshot (which is now the new key's). + const dataBeforeLateEvent = result.current.data; + await act(async () => { + await firstConnectArgs.onMessage({ + data: JSON.stringify({ + type: "result", + data: [{ id: 1, old: true }], + }), + }); + }); + + // The old key's late result should not have affected the current snapshot. + expect(result.current.data).toBe(dataBeforeLateEvent); + }, 15000); + + test("data is latest-only (never accumulates across polls)", async () => { + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { + poll: { immediate: true, intervalMs: 300 }, + }), + ); + + // First poll. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ poll: 1 }] }), + }); + }); + expect(result.current.data).toEqual([{ poll: 1 }]); + + // Second poll (300ms). + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ poll: 2 }] }), + }); + }); + expect(result.current.data).toEqual([{ poll: 2 }]); + + // Third poll (600ms). + await act(async () => { + await vi.advanceTimersByTimeAsync(300); + }); + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ poll: 3 }] }), + }); + }); + // Should be LATEST only, not accumulated. + expect(result.current.data).toEqual([{ poll: 3 }]); + }); + + test("loading toggles per in-flight poll tick", async () => { + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { + poll: { immediate: true, intervalMs: 500 }, + }), + ); + + // Start: immediate tick fires, loading should be true. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(true); + + // Complete the request. + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ id: 1 }] }), + }); + }); + expect(result.current.loading).toBe(false); + + // Next poll tick fires, loading should be true again. + await act(async () => { + await vi.advanceTimersByTimeAsync(500); + }); + expect(result.current.loading).toBe(true); + + // Complete. + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ id: 2 }] }), + }); + }); + expect(result.current.loading).toBe(false); + }); + + test("warehouseStatus surfaces during cold poll", async () => { + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { + poll: { immediate: true }, + }), + ); + + // Immediate tick fires. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.loading).toBe(true); + expect(result.current.warehouseStatus).toBeNull(); + + // Warehouse status event arrives. + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ + type: "warehouse_status", + status: { state: "STARTING", elapsedMs: 500 }, + }), + }); + }); + expect(result.current.warehouseStatus).toEqual({ + state: "STARTING", + elapsedMs: 500, + }); + expect(result.current.loading).toBe(true); + + // Result arrives. + await act(async () => { + await lastConnectArgs.onMessage({ + data: JSON.stringify({ type: "result", data: [{ id: 1 }] }), + }); + }); + expect(result.current.loading).toBe(false); + expect(result.current.warehouseStatus).toEqual({ + state: "STARTING", + elapsedMs: 500, + }); + }); + + test("poll field is present and typed when polling is enabled", () => { + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { poll: true }), + ); + + expect(result.current.poll).toBeDefined(); + expect(result.current.poll?.paused).toBe(false); + expect(result.current.poll?.pause).toBeInstanceOf(Function); + expect(result.current.poll?.resume).toBeInstanceOf(Function); + expect(result.current.poll?.restart).toBeInstanceOf(Function); + expect(result.current.poll?.refetch).toBeInstanceOf(Function); + expect(result.current.poll?.attempts).toEqual(0); + expect(result.current.poll?.errors).toEqual(0); + expect(result.current.poll?.skipped).toEqual(0); + expect(result.current.poll?.consecutiveErrors).toEqual(0); + expect(result.current.poll?.lastLatencyMs).toBe(null); + expect(result.current.poll?.latency).toEqual({ p50: null, p95: null }); + }); + + test("poll field is absent when polling is not enabled", () => { + const { result } = renderHook(() => useAnalyticsQuery("q", null)); + + expect(result.current.poll).toBeUndefined(); + }); + + test("poll pause/resume controls work correctly", async () => { + const { result } = renderHook(() => + useAnalyticsQuery("q", null, { + poll: { intervalMs: 100, immediate: true }, + }), + ); + + // Immediate tick. + await act(async () => { + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.poll?.attempts).toBe(1); + + // Pause. + await act(async () => { + result.current.poll?.pause(); + }); + expect(result.current.poll?.paused).toBe(true); + + // Advance time; no new tick should fire while paused. + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + expect(result.current.poll?.attempts).toBe(1); + + // Resume. + await act(async () => { + result.current.poll?.resume(); + await vi.advanceTimersByTimeAsync(0); + }); + expect(result.current.poll?.paused).toBe(false); + // Resume fires immediately, then continues on cadence. + expect(result.current.poll?.attempts).toBe(2); + }); + }); }); diff --git a/packages/appkit-ui/src/react/hooks/index.ts b/packages/appkit-ui/src/react/hooks/index.ts index 9e4d51716..4fd293714 100644 --- a/packages/appkit-ui/src/react/hooks/index.ts +++ b/packages/appkit-ui/src/react/hooks/index.ts @@ -33,6 +33,8 @@ export type { ServingEndpointRegistry, TypedArrowTable, UseAnalyticsQueryOptions, + UseAnalyticsQueryPollOptions, + UseAnalyticsQueryPollResult, UseAnalyticsQueryResult, UseMetricViewOptions, UseMetricViewResult, diff --git a/packages/appkit-ui/src/react/hooks/types.ts b/packages/appkit-ui/src/react/hooks/types.ts index 92ae9f45a..58714be96 100644 --- a/packages/appkit-ui/src/react/hooks/types.ts +++ b/packages/appkit-ui/src/react/hooks/types.ts @@ -44,6 +44,129 @@ export interface TypedArrowTable< // Query Options & Result Types // ============================================================================ +/** + * Polling configuration for continuous re-execution of analytics queries. + * + * When enabled, the query will re-execute on a defined interval, always bypassing + * the server cache to fetch fresh data. Each poll tick represents a new database + * round-trip. + */ +export interface UseAnalyticsQueryPollOptions { + /** + * Interval between poll ticks in milliseconds. Default: 1000. + * Ignored if `poll` is a plain `true`; use the default interval instead. + */ + intervalMs?: number; + + /** + * If true (default), fire once at t=0 before entering the interval cadence. + * Ignored if `poll` is a plain `true`; use the default behavior (fire immediately). + */ + immediate?: boolean; + + /** + * Exponential backoff configuration applied on poll errors. + * Default: 1→2→4→8→8… seconds, capped at 8 seconds. + * Ignored if `poll` is a plain `true`; use the default backoff. + */ + backoff?: { + /** Base backoff in milliseconds. Default: 1000. */ + baseMs?: number; + /** Exponential multiplier. Default: 2. */ + multiplier?: number; + /** Maximum backoff in milliseconds. Default: 8000. */ + maxMs?: number; + }; + + /** + * Pause the polling scheduler after this many consecutive errors. + * If unset, the scheduler retries indefinitely. + * Ignored if `poll` is a plain `true`; no max-error threshold applied. + */ + maxConsecutiveErrors?: number; + + /** + * Callback fired on each poll settle (after each request completes or errors). + * Receives the current settled state: `data`, `loading`, `error`, `errorCode`, `warehouseStatus`. + * Useful for side effects, metrics, or aborting polling based on conditions. + */ + onPoll?: (result: { + data: unknown; + loading: boolean; + error: string | null; + errorCode: string | null; + warehouseStatus: WarehouseStatus | null; + }) => void; +} + +/** + * Telemetry and controls returned by polling in `useAnalyticsQuery`. + * Present only when the `poll` option is enabled. + */ +export interface UseAnalyticsQueryPollResult { + /** + * True if the polling scheduler is paused (no ticking, no pending timer). + */ + paused: boolean; + + /** + * Pause the polling scheduler. No more ticks will fire until resume() is called. + */ + pause: () => void; + + /** + * Resume the polling scheduler. Polls immediately, then resumes the interval cadence. + */ + resume: () => void; + + /** + * Restart the polling scheduler. Clears all telemetry, backoff state, and in-flight + * tracking. The scheduler will not fire again unless resume() or refetch() is called. + */ + restart: () => void; + + /** + * Trigger an out-of-band poll immediately, ignoring the interval and backoff state. + * If a poll is already in-flight, this is skipped (increments skipped count). + */ + refetch: () => void; + + /** + * Total number of completed (settled) poll attempts. + */ + attempts: number; + + /** + * Total number of poll attempts that encountered an error. + */ + errors: number; + + /** + * Total number of poll ticks that were skipped because a request was already in-flight. + */ + skipped: number; + + /** + * Current count of consecutive errors. Resets to 0 on a successful poll. + */ + consecutiveErrors: number; + + /** + * Latency (in milliseconds) of the most recently settled poll, or null if no polls have settled yet. + */ + lastLatencyMs: number | null; + + /** + * Latency percentiles computed over all settled polls. + */ + latency: { + /** 50th percentile (median) latency, or null if no polls have settled. */ + p50: number | null; + /** 95th percentile latency, or null if fewer than 20 samples exist. */ + p95: number | null; + }; +} + /** Options for configuring an analytics SSE query */ export interface UseAnalyticsQueryOptions< F extends AnalyticsFormat = "JSON_ARRAY", @@ -56,6 +179,13 @@ export interface UseAnalyticsQueryOptions< /** Whether to automatically start the query when the hook is mounted. Default is true. */ autoStart?: boolean; + + /** + * Enable polling: re-execute the query on a defined interval, always bypassing + * the server cache for fresh data. Pass `true` for defaults (1s interval, immediate) + * or an object to customize interval, backoff, and other behavior. + */ + poll?: true | UseAnalyticsQueryPollOptions; } /** @@ -109,6 +239,10 @@ export interface UseAnalyticsQueryResult { * remains `null` for cache hits where the server skips the readiness check. */ warehouseStatus: WarehouseStatus | null; + /** + * Polling controls and telemetry. Present only when the `poll` option is enabled. + */ + poll?: UseAnalyticsQueryPollResult; } /** diff --git a/packages/appkit-ui/src/react/hooks/use-analytics-query.ts b/packages/appkit-ui/src/react/hooks/use-analytics-query.ts index e5f9e5e4b..c38bd6622 100644 --- a/packages/appkit-ui/src/react/hooks/use-analytics-query.ts +++ b/packages/appkit-ui/src/react/hooks/use-analytics-query.ts @@ -18,6 +18,7 @@ import type { UseAnalyticsQueryResult, } from "./types"; import { useAnalyticsWarehousePublisher } from "./use-analytics-warehouse-status"; +import { usePoll } from "./use-poll"; import { useQueryHMR } from "./use-query-hmr"; /** Shallow equality for plain-object query parameters (primitive values only). */ @@ -105,7 +106,11 @@ export function useAnalyticsQuery< ): UseAnalyticsQueryResult> { const format = options?.format ?? "JSON_ARRAY"; const maxParametersSize = options?.maxParametersSize ?? 100 * 1024; - const autoStart = options?.autoStart ?? true; + const pollOption = options?.poll; + const isPolling = pollOption !== undefined; + // When polling, autoStart is always false (scheduler is sole trigger). + // Otherwise, respect the autoStart option (default true). + const autoStart = isPolling ? false : (options?.autoStart ?? true); const devMode = getDevMode(); const urlSuffix = `/api/analytics/query/${encodeURIComponent(queryKey)}${devMode}`; @@ -152,6 +157,14 @@ export function useAnalyticsQuery< // request is retained and the store reports the stable idle snapshot. const cacheKey = `${urlSuffix}::${payload}`; + // Track the current cacheKey to guard against late-event races. + // When params change to a new key mid-flight, late completions from the old key + // will patch the old key's entry, not the current entry. + const cacheKeyRef = useRef(cacheKey); + useEffect(() => { + cacheKeyRef.current = cacheKey; + }, [cacheKey]); + const subscribe = useCallback( (listener: () => void) => store.subscribe(cacheKey, listener), [cacheKey], @@ -167,6 +180,8 @@ export function useAnalyticsQuery< // Register with the shared store on mount / key change; release on cleanup. // The store starts the request on first retain of a key and reuses the // in-flight request for later subscribers. + // When polling is enabled, autoStart is false so the scheduler (usePoll) + // is the sole trigger via store.refetch(). useEffect(() => { if (payload === null) return; return store.retain( @@ -195,7 +210,62 @@ export function useAnalyticsQuery< useQueryHMR(queryKey, start); - return { + // === Polling Integration === + // When polling is enabled, integrate the usePoll scheduler. + // Each tick calls refetch(cacheKey) to force uncached re-execution of the current key. + const pollRunOnce = useCallback(async () => { + // Call refetch with the CURRENT cacheKey to guard against late-event races. + // If params changed mid-flight, the old key's completion patches the old entry; + // only the current key's completion updates the snapshot we read from. + store.refetch(cacheKeyRef.current); + }, []); + + const pollConfig = useMemo(() => { + if (!isPolling) { + // When polling is not enabled, return a config that keeps the scheduler paused. + return { immediate: false }; + } + // If poll is a plain `true`, use all defaults (which includes immediate: true). + if (pollOption === true) { + return {}; + } + // Otherwise, extract the config object. + return { + intervalMs: (pollOption as any).intervalMs, + immediate: (pollOption as any).immediate, + backoff: (pollOption as any).backoff, + maxConsecutiveErrors: (pollOption as any).maxConsecutiveErrors, + }; + }, [isPolling, pollOption]); + + const pollResult = usePoll(pollRunOnce, pollConfig); + + // Fire the onPoll callback on each settle (after each poll tick completes). + // The callback receives the current snapshot state. + useEffect(() => { + if (!isPolling || pollOption === true) return; + const onPoll = (pollOption as any).onPoll; + if (!onPoll) return; + + // Fire the callback whenever the snapshot changes and the poll has settled. + onPoll({ + data: snapshot.data, + loading: snapshot.loading, + error: snapshot.error, + errorCode: snapshot.errorCode, + warehouseStatus: snapshot.warehouseStatus, + }); + }, [ + isPolling, + pollOption, + snapshot.data, + snapshot.loading, + snapshot.error, + snapshot.errorCode, + snapshot.warehouseStatus, + ]); + + const result: UseAnalyticsQueryResult = { data: snapshot.data as ResultType | null, loading: snapshot.loading, // A serialization failure never creates a store entry, so surface it here. @@ -206,4 +276,23 @@ export function useAnalyticsQuery< errorCode: snapshot.errorCode, warehouseStatus: snapshot.warehouseStatus, }; + + // Add polling controls and telemetry if polling is enabled. + if (isPolling) { + result.poll = { + paused: pollResult.paused, + pause: pollResult.pause, + resume: pollResult.resume, + restart: pollResult.restart, + refetch: pollResult.refetch, + attempts: pollResult.attempts, + errors: pollResult.errors, + skipped: pollResult.skipped, + consecutiveErrors: pollResult.consecutiveErrors, + lastLatencyMs: pollResult.lastLatencyMs, + latency: pollResult.latency, + }; + } + + return result; }