Skip to content
Merged
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
42 changes: 42 additions & 0 deletions src/components/ContractEventFeed.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,48 @@ describe("ContractEventFeed", () => {
expect(getEvents).toHaveBeenCalledTimes(callsAfterPause);
});

it("pauses polling while the screen is hidden and resumes when visible again (#533)", async () => {
const getEvents = vi.fn().mockResolvedValue({ data: [], error: null });
vi.mocked(getClient).mockReturnValue({
soroban: { getEvents },
} as unknown as SorokitClient);

let observerCallback: IntersectionObserverCallback | undefined;
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(callback: IntersectionObserverCallback) {
observerCallback = callback;
}
observe = vi.fn();
disconnect = vi.fn();
unobserve = vi.fn();
},
);

render(<ContractEventFeed contractId={CONTRACT_ID} pollInterval={500} />);
act(() => { vi.advanceTimersByTime(0); });
await waitFor(() => expect(getEvents).toHaveBeenCalledTimes(1));

// Dashboard hides this screen (mount-once, keep-alive pattern).
act(() => {
observerCallback?.([{ isIntersecting: false } as IntersectionObserverEntry], {} as IntersectionObserver);
});

// Well past the poll interval while hidden — no new calls.
act(() => { vi.advanceTimersByTime(1500); });
expect(getEvents).toHaveBeenCalledTimes(1);

// Becomes visible again — polling resumes.
act(() => {
observerCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});
act(() => { vi.advanceTimersByTime(500) });
await waitFor(() => expect(getEvents).toHaveBeenCalledTimes(2));

vi.unstubAllGlobals();
});

it("triggers a new load when contractId changes", async () => {
const getEvents = vi.fn().mockResolvedValue({ data: [], error: null });
vi.mocked(getClient).mockReturnValue({
Expand Down
26 changes: 20 additions & 6 deletions src/components/ContractEventFeed.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";

import { Badge } from "@/components/ui/Badge";
import { useSorokit } from "@/context/useSorokit";
import { useIsVisible } from "@/hooks/useIsVisible";
import type { ContractEvent } from "@/lib/client";
import { cn, truncateAddress } from "@/lib/utils";

Expand Down Expand Up @@ -249,6 +250,7 @@ export function ContractEventFeed({
filterTypes ? new Set(filterTypes) : null,
);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const [containerRef, isVisible] = useIsVisible<HTMLDivElement>();

// IDs highlighted as newly-arrived. `prevEventIdsRef` is the baseline from
// the previous successful load — `null` means no baseline yet, so the very
Expand Down Expand Up @@ -332,7 +334,14 @@ export function ContractEventFeed({
}, [load]);

useEffect(() => {
if (live && pollInterval > 0 && contractId.trim() !== "") {
// Dashboard keeps a visited screen mounted rather than unmounting it,
// to preserve in-progress state — see the comment in Dashboard.tsx.
// Gating on isVisible (in addition to the user-facing `live` toggle)
// stops this from polling in the background once its screen is no
// longer the active one (#533), without disturbing `live`'s own
// on/off semantics — resuming visibility restores whatever `live` was
// already set to.
if (live && isVisible && pollInterval > 0 && contractId.trim() !== "") {
intervalRef.current = setInterval(() => {
void load();
}, pollInterval);
Expand All @@ -342,14 +351,16 @@ export function ContractEventFeed({
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [live, pollInterval, load, contractId]);
}, [live, isVisible, pollInterval, load, contractId]);

// Tick the relative "Last updated" label once a second while polling is active.
// Tick the relative "Last updated" label once a second while polling is
// active and visible — ticking a hidden screen's clock wastes a timer for
// a label nobody can see.
useEffect(() => {
if (!live || pollInterval <= 0) return;
if (!live || !isVisible || pollInterval <= 0) return;
const tickId = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(tickId);
}, [live, pollInterval]);
}, [live, isVisible, pollInterval]);

const typeCounts = useMemo(() => {
const counts = new Map<string, number>();
Expand Down Expand Up @@ -387,7 +398,10 @@ export function ContractEventFeed({
activeTypes ? activeTypes.has(type) : true;

return (
<div className={cn("rounded-xl border border-line bg-surface overflow-hidden", className)}>
<div
ref={containerRef}
className={cn("rounded-xl border border-line bg-surface overflow-hidden", className)}
>
<div className="flex items-center justify-between px-5 py-4 border-b border-line">
<div>
<h3 className="text-[14px] font-semibold text-ink">
Expand Down
62 changes: 62 additions & 0 deletions src/components/FeeEstimator.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,68 @@ describe("FeeEstimator", { timeout: 15000 }, () => {

vi.useRealTimers();
});

it("pauses polling while hidden and resumes when visible again (#533)", async () => {
vi.useFakeTimers();
const estimateFee = vi.fn().mockResolvedValue({
data: { baseFee: "100", recommended: "500" },
error: null,
});
vi.mocked(getClient).mockReturnValue({
transaction: { estimateFee },
} as unknown as SorokitClient);

let observerCallback: IntersectionObserverCallback | undefined;
const observe = vi.fn();
const disconnect = vi.fn();
vi.stubGlobal(
"IntersectionObserver",
class {
constructor(callback: IntersectionObserverCallback) {
observerCallback = callback;
}
observe = observe;
disconnect = disconnect;
unobserve = vi.fn();
},
);

render(<FeeEstimator refreshInterval={5000} />);

await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(estimateFee).toHaveBeenCalledTimes(1);

// Simulate Dashboard hiding this screen (the ContractEventFeed-style
// "mount once, keep alive" pattern — see Dashboard.tsx).
act(() => {
observerCallback?.([{ isIntersecting: false } as IntersectionObserverEntry], {} as IntersectionObserver);
});

// Time passes while hidden — no new calls should fire.
await act(async () => {
await vi.advanceTimersByTimeAsync(10_000);
});
expect(estimateFee).toHaveBeenCalledTimes(1);

// Becomes visible again — polling resumes.
act(() => {
observerCallback?.([{ isIntersecting: true } as IntersectionObserverEntry], {} as IntersectionObserver);
});
await act(async () => {
await vi.advanceTimersByTimeAsync(0);
});
expect(estimateFee).toHaveBeenCalledTimes(2);

await act(async () => {
await vi.advanceTimersByTimeAsync(5000);
});
expect(estimateFee).toHaveBeenCalledTimes(3);

vi.useRealTimers();
vi.unstubAllGlobals();
});
});

describe("FeeCell export", () => {
Expand Down
12 changes: 11 additions & 1 deletion src/components/FeeEstimator.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useCallback, useEffect, useState } from "react";
import { Badge } from "@/components/ui/Badge";
import { Tooltip } from "@/components/ui/Tooltip";
import { useSorokit } from "@/context/useSorokit";
import { useIsVisible } from "@/hooks/useIsVisible";
import { cn, toXLM } from "@/lib/utils";

export interface FeeData {
Expand Down Expand Up @@ -32,6 +33,7 @@ export function FeeEstimator({
const [fee, setFee] = useState<FeeData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [containerRef, isVisible] = useIsVisible<HTMLDivElement>();

const load = useCallback(async () => {
if (!client) return;
Expand All @@ -53,6 +55,13 @@ export function FeeEstimator({
}, [client, onFeeLoad]);

useEffect(() => {
// Dashboard keeps a visited screen mounted (rather than unmounting it)
// to preserve in-progress state — see the comment in Dashboard.tsx.
// That means a screen navigated away from is still mounted, just
// hidden; without this check, a refreshInterval keeps firing network
// requests for a screen the user can no longer see (#533).
if (!isVisible) return;

const timerId = window.setTimeout(() => {
void load();
}, 0);
Expand All @@ -68,14 +77,15 @@ export function FeeEstimator({
return () => {
window.clearTimeout(timerId);
};
}, [load, refreshInterval]);
}, [load, refreshInterval, isVisible]);

const compactContent = fee
? `Base: ${fee.baseFee} stroops · Recommended: ${fee.recommended} stroops`
: null;

return (
<div
ref={containerRef}
role="region"
aria-label="Network fee estimate"
className={cn(
Expand Down
137 changes: 137 additions & 0 deletions src/hooks/useIsVisible.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";

import { useIsVisible } from "./useIsVisible";

/**
* A controllable IntersectionObserver stub. Real IntersectionObserver
* entries only arrive asynchronously after layout, which jsdom never
* performs — tests drive visibility changes explicitly via
* `triggerIntersection(entry)` instead of relying on real layout/scroll.
*/
class MockIntersectionObserver {
static instances: MockIntersectionObserver[] = [];
callback: IntersectionObserverCallback;
observedNode: Element | null = null;
disconnected = false;

constructor(callback: IntersectionObserverCallback) {
this.callback = callback;
MockIntersectionObserver.instances.push(this);
}

observe(node: Element) {
this.observedNode = node;
}

unobserve() {
this.observedNode = null;
}

disconnect() {
this.disconnected = true;
}

trigger(isIntersecting: boolean) {
this.callback(
[{ isIntersecting } as IntersectionObserverEntry],
this as unknown as IntersectionObserver,
);
}
}

describe("useIsVisible", () => {
beforeEach(() => {
MockIntersectionObserver.instances = [];
vi.stubGlobal("IntersectionObserver", MockIntersectionObserver);
});

afterEach(() => {
vi.unstubAllGlobals();
});

it("starts visible (optimistic) before the observer fires", () => {
const { result } = renderHook(() => useIsVisible<HTMLDivElement>());
const [, isVisible] = result.current;
expect(isVisible).toBe(true);
});

it("does nothing until the ref is attached to a node", () => {
renderHook(() => useIsVisible<HTMLDivElement>());
// No node was ever assigned to ref.current, so observe() is never
// called and no observer instance should have been constructed with
// an observed node.
expect(
MockIntersectionObserver.instances.every((i) => i.observedNode === null),
).toBe(true);
});

it("updates to false when the observed element is not intersecting", () => {
const node = document.createElement("div");
const { result, rerender } = renderHook(() => {
const [ref, isVisible] = useIsVisible<HTMLDivElement>();
// Attach the node on every render, the way a component's `ref={ref}`
// JSX prop would on mount.
ref.current = node;
return { ref, isVisible };
});

// Manually drive the effect that calls observe() by attaching the node
// before the first effect run, then forcing the effect to have run via
// renderHook's act-wrapped initial render.
expect(MockIntersectionObserver.instances.length).toBe(1);
act(() => {
MockIntersectionObserver.instances[0]!.trigger(false);
});
rerender();
expect(result.current.isVisible).toBe(false);
});

it("updates back to true when the observed element becomes intersecting again", () => {
const node = document.createElement("div");
const { result, rerender } = renderHook(() => {
const [ref, isVisible] = useIsVisible<HTMLDivElement>();
ref.current = node;
return { ref, isVisible };
});

act(() => {
MockIntersectionObserver.instances[0]!.trigger(false);
});
rerender();
expect(result.current.isVisible).toBe(false);

act(() => {
MockIntersectionObserver.instances[0]!.trigger(true);
});
rerender();
expect(result.current.isVisible).toBe(true);
});

it("disconnects the observer on unmount", () => {
const node = document.createElement("div");
const { unmount } = renderHook(() => {
const [ref] = useIsVisible<HTMLDivElement>();
ref.current = node;
return ref;
});

expect(MockIntersectionObserver.instances.length).toBe(1);
unmount();
expect(MockIntersectionObserver.instances[0]!.disconnected).toBe(true);
});

it("fails open (stays visible) when IntersectionObserver is unavailable", () => {
vi.unstubAllGlobals();
vi.stubGlobal("IntersectionObserver", undefined);

const node = document.createElement("div");
const { result } = renderHook(() => {
const [ref, isVisible] = useIsVisible<HTMLDivElement>();
ref.current = node;
return isVisible;
});

expect(result.current).toBe(true);
});
});
Loading
Loading