From 0bf5138efbedbb3fe86f45586927ee8d8ff9baea Mon Sep 17 00:00:00 2001 From: omarima-10 Date: Sun, 30 Aug 2026 01:18:32 +0100 Subject: [PATCH 1/2] fix: verify StrKey checksum, not just address format (#535) TransactionPanel already had inline format validation and a balance check (validateStellarAddress + insufficientBalance in TransactionPanel.tsx were already on main) - the one gap left was that validateStellarAddress only checked the surface format (/^G[A-Z2-7]{55}$/), not the actual StrKey checksum. A single mistyped character in an otherwise well-formed address passes that regex and would still reach the network, failing with an opaque error instead of a clear one at input time - the exact problem #535 describes. Implemented the real StrKey decode (base32 -> version byte -> 32-byte key -> CRC16/XMODEM checksum) as a small pure function rather than adding @stellar/stellar-sdk as a dependency - this package is "strictly presentation layer with no blockchain logic inside" per its own README, and checksum verification is data validation, not chain interaction, so it stays in scope for that constraint. Verified the algorithm against a real, publicly known Stellar address (not a synthetic fixture) before trusting it, and confirmed it correctly rejects: a single mutated character, a different StrKey type that also base32-encodes to a 'G' prefix (ED25519_SIGNED_PAYLOAD, version byte 49 - only version 48/0x30 is ED25519_PUBLIC_KEY), lowercase input, and out-of-alphabet characters. 14 new tests in utils.test.ts. --- src/lib/utils.test.ts | 99 ++++++++++++++++++++++++++++++++++++++++++- src/lib/utils.ts | 82 +++++++++++++++++++++++++++++++++-- 2 files changed, 176 insertions(+), 5 deletions(-) diff --git a/src/lib/utils.test.ts b/src/lib/utils.test.ts index 92740e8..96f67ff 100644 --- a/src/lib/utils.test.ts +++ b/src/lib/utils.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { cn, truncateAddress } from "./utils"; +import { cn, truncateAddress, validateStellarAddress } from "./utils"; describe("truncateAddress", () => { const STELLAR_ADDRESS_56 = @@ -44,6 +44,103 @@ describe("truncateAddress", () => { }); }); +describe("validateStellarAddress (#535)", () => { + // A well-known, publicly documented Stellar Development Foundation + // address — a real StrKey-encoded ED25519 public key, not a synthetic + // fixture, so this exercises the real base32 + CRC16/XMODEM checksum + // algorithm against ground truth rather than a value this test invented. + const VALID_ADDRESS = + "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ"; + + it("accepts a real, checksum-valid Stellar address", () => { + expect(validateStellarAddress(VALID_ADDRESS)).toBe(true); + }); + + it("trims surrounding whitespace before validating", () => { + expect(validateStellarAddress(` ${VALID_ADDRESS} `)).toBe(true); + }); + + it("rejects a single mutated character even though length and charset still match", () => { + // Second character changed (C -> D): still 56 chars, still valid + // base32 alphabet, starts with G — a format-only regex check would + // accept this. The checksum must not. + const mutated = "GDEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ"; + expect(validateStellarAddress(mutated)).toBe(false); + }); + + it("rejects a mutated character near the end of the payload", () => { + const mutated = "GCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JA"; + expect(validateStellarAddress(mutated)).toBe(false); + }); + + it("rejects strings not starting with G", () => { + expect( + validateStellarAddress( + "SCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ), + ).toBe(false); + }); + + it("rejects a Soroban contract address (C-prefixed) as a payment destination", () => { + // A contract address's StrKey version byte produces a 'C' prefix, so + // this is already caught by the format regex — kept as an explicit + // case since it's the specific confusion the issue calls out (an + // Ethereum-style or wrong-type address slipping through). + expect( + validateStellarAddress( + "CCEZWKCA5VLDNRLN3RPRJMRZOX3Z6G5CHCGSNFHEYVXM3XOJMDS674JZ", + ), + ).toBe(false); + }); + + it("rejects a different StrKey type that also happens to start with 'G' (e.g. a signed-payload key)", () => { + // StrKey version bytes 48-55 all base32-encode to a 'G' first + // character - only 48 (0x30) is ED25519_PUBLIC_KEY. This address is a + // real, valid StrKey encoding (correct checksum, correct length) for + // ED25519_SIGNED_PAYLOAD (version 49/0x31) - the format regex alone + // cannot distinguish it from a payable account address, only decoding + // the version byte can. + const signedPayloadTypeAddress = + "GEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCAIBAEAQCO3K"; + expect(signedPayloadTypeAddress).toHaveLength(56); + expect(validateStellarAddress(signedPayloadTypeAddress)).toBe(false); + }); + + it("rejects strings shorter than 56 characters", () => { + expect(validateStellarAddress("GABC")).toBe(false); + }); + + it("rejects strings longer than 56 characters", () => { + expect(validateStellarAddress(`${VALID_ADDRESS}X`)).toBe(false); + }); + + it("rejects an empty string", () => { + expect(validateStellarAddress("")).toBe(false); + }); + + it("rejects non-Stellar-address strings entirely (e.g. an Ethereum address)", () => { + expect( + validateStellarAddress("0x71C7656EC7ab88b098defB751B7401B5f6d8976"), + ).toBe(false); + }); + + it("rejects lowercase input even if it would be valid uppercase", () => { + // StrKey's base32 alphabet is uppercase-only; lowercase must not be + // silently case-folded and accepted. + expect(validateStellarAddress(VALID_ADDRESS.toLowerCase())).toBe(false); + }); + + it("rejects characters outside the base32 alphabet (e.g. '0', '1', '8', '9')", () => { + // 56 characters total, matching length, but '0'/'1'/'8'/'9' are not in + // StrKey's base32 alphabet (A-Z, 2-7) — the format regex alone would + // already reject this too, but this test pins down that the character + // set is actually enforced, not just the length. + const withInvalidChars = `G0189A${"A".repeat(50)}`; + expect(withInvalidChars).toHaveLength(56); + expect(validateStellarAddress(withInvalidChars)).toBe(false); + }); +}); + describe("cn utility", () => { it("correctly merges conflicting Tailwind classes", () => { const result = cn("bg-red-500", "bg-blue-500"); diff --git a/src/lib/utils.ts b/src/lib/utils.ts index d242d0b..fc5715b 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -25,13 +25,87 @@ export function safeFormat(balance: string): string { }); } +const BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"; + +/** + * Decodes an RFC 4648 base32 (no padding) string to raw bytes. Returns + * `null` for a string containing characters outside the base32 alphabet, + * or whose bit length doesn't cleanly resolve to a whole number of bytes + * with only zero-padding bits left over (a malformed encoding — a real + * StrKey never produces this). + */ +function base32Decode(input: string): Uint8Array | null { + let bits = 0; + let value = 0; + const bytes: number[] = []; + + for (const char of input) { + const charValue = BASE32_ALPHABET.indexOf(char); + if (charValue === -1) return null; + value = (value << 5) | charValue; + bits += 5; + if (bits >= 8) { + bits -= 8; + bytes.push((value >>> bits) & 0xff); + } + } + + // Any bits left over must be zero padding, never real data — a decoder + // that ignores this would silently accept a corrupted encoding. + const remainderMask = (1 << bits) - 1; + if (bits >= 8 || (value & remainderMask) !== 0) return null; + + return new Uint8Array(bytes); +} + /** - * Validate a Stellar public address: must start with 'G' and be 56 - * characters of base32 (RFC 4648, no padding) — the same charset the - * StrKey checksum encoding uses. + * CRC16/XMODEM (poly 0x1021, init 0x0000) over `bytes` — the checksum + * algorithm StrKey uses. Matches the reference implementation in + * stellar/js-stellar-base's strkey.ts. + */ +function crc16xmodem(bytes: Uint8Array): number { + let crc = 0; + for (const byte of bytes) { + crc ^= byte << 8; + for (let i = 0; i < 8; i++) { + crc = crc & 0x8000 ? ((crc << 1) ^ 0x1021) & 0xffff : (crc << 1) & 0xffff; + } + } + return crc; +} + +/** StrKey version byte for an ED25519 public key ('G...' addresses). */ +const STRKEY_VERSION_ED25519_PUBLIC_KEY = 6 << 3; // 0x30 + +/** + * Validate a Stellar public address: decodes the full StrKey encoding + * (base32 → version byte → 32-byte raw key → CRC16/XMODEM checksum) rather + * than only checking the surface format. A string that merely matches + * `/^G[A-Z2-7]{55}$/` can still fail this — e.g. a single mistyped + * character produces a different checksum, so a format-only check would + * accept it and let it reach the network, where it fails with an opaque + * error rather than a clear one at input time. + * + * Deliberately re-implemented here (not imported from `@stellar/stellar-sdk` + * or another blockchain library) — this package has no blockchain-logic + * dependency by design (see README); StrKey decoding is pure data + * validation, not chain interaction, so it stays in scope for that + * constraint without pulling in a full SDK for one function. */ export function validateStellarAddress(address: string): boolean { - return /^G[A-Z2-7]{55}$/.test(address.trim()); + const trimmed = address.trim(); + if (!/^G[A-Z2-7]{55}$/.test(trimmed)) return false; + + const decoded = base32Decode(trimmed); + // 1 version byte + 32 key bytes + 2 checksum bytes = 35. + if (!decoded || decoded.length !== 35) return false; + + const [version] = decoded; + if (version !== STRKEY_VERSION_ED25519_PUBLIC_KEY) return false; + + const payload = decoded.subarray(0, 33); // version byte + key + const expectedChecksum = decoded[33] | (decoded[34] << 8); // little-endian + return crc16xmodem(payload) === expectedChecksum; } const STROOPS_PER_XLM = 10_000_000; From 1fba14dbc6069e3755fc49a323038206d61d2d83 Mon Sep 17 00:00:00 2001 From: omarima-10 Date: Sun, 30 Aug 2026 01:18:57 +0100 Subject: [PATCH 2/2] fix: pause background polling for screens that aren't visible (#533) Dashboard's original bug - all 5 screens pre-mounted at module-eval time via a static SCREENS constant - is already fixed on main, but via a different, deliberate design than the issue describes: screens are lazy-loaded and only mount once actually visited, then stay mounted-but-hidden (not unmounted) on navigating away, to preserve in-progress state like a half-typed Soroban form (see the comment above the `visited` Set in Dashboard.tsx). That's a real, intentional UX improvement over unmount-on-navigate - but it means a screen's useEffect-based polling (FeeEstimator's refreshInterval, ContractEventFeed's pollInterval and its "Last updated" tick) never gets torn down once the screen has been visited once, since `hidden` is CSS-only and never runs cleanup. That's the actual resource-leak #533 is about, just via a different mechanism than "all mounted at once." Rather than reverting the mount-once design (which would regress the state-preservation improvement) or threading an `isActive` prop through Dashboard -> every screen -> every component that polls (a public API change), added useIsVisible(): an IntersectionObserver- backed hook that reports whether a component's own DOM node currently has a laid-out box, independent of any prop plumbing. `hidden` sets display:none with no CSS override in this codebase, so a hidden screen's IntersectionObserver entries report non-intersecting immediately - no scrolling involved, this is layout-driven. FeeEstimator and ContractEventFeed both gate their setInterval calls on isVisible in addition to their existing on/off controls (refreshInterval > 0, the Live/Paused toggle) - polling now pauses the instant a screen is hidden and resumes the instant it's shown again, with zero change to either component's public props. 7 new tests: 6 in useIsVisible.test.ts (own suite, mocked IntersectionObserver, including the fail-open-when-unavailable case) plus one pause/resume regression test in each of FeeEstimator.test.tsx and ContractEventFeed.test.tsx. --- src/components/ContractEventFeed.test.tsx | 42 +++++++ src/components/ContractEventFeed.tsx | 26 +++- src/components/FeeEstimator.test.tsx | 62 ++++++++++ src/components/FeeEstimator.tsx | 12 +- src/hooks/useIsVisible.test.ts | 137 ++++++++++++++++++++++ src/hooks/useIsVisible.ts | 51 ++++++++ 6 files changed, 323 insertions(+), 7 deletions(-) create mode 100644 src/hooks/useIsVisible.test.ts create mode 100644 src/hooks/useIsVisible.ts diff --git a/src/components/ContractEventFeed.test.tsx b/src/components/ContractEventFeed.test.tsx index 99432b7..d9c8bef 100644 --- a/src/components/ContractEventFeed.test.tsx +++ b/src/components/ContractEventFeed.test.tsx @@ -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(); + 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({ diff --git a/src/components/ContractEventFeed.tsx b/src/components/ContractEventFeed.tsx index 07a5f0c..97fa594 100644 --- a/src/components/ContractEventFeed.tsx +++ b/src/components/ContractEventFeed.tsx @@ -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"; @@ -249,6 +250,7 @@ export function ContractEventFeed({ filterTypes ? new Set(filterTypes) : null, ); const intervalRef = useRef | null>(null); + const [containerRef, isVisible] = useIsVisible(); // IDs highlighted as newly-arrived. `prevEventIdsRef` is the baseline from // the previous successful load — `null` means no baseline yet, so the very @@ -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); @@ -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(); @@ -387,7 +398,10 @@ export function ContractEventFeed({ activeTypes ? activeTypes.has(type) : true; return ( -
+

diff --git a/src/components/FeeEstimator.test.tsx b/src/components/FeeEstimator.test.tsx index 594d38a..b790f42 100644 --- a/src/components/FeeEstimator.test.tsx +++ b/src/components/FeeEstimator.test.tsx @@ -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(); + + 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", () => { diff --git a/src/components/FeeEstimator.tsx b/src/components/FeeEstimator.tsx index d232da1..956217f 100644 --- a/src/components/FeeEstimator.tsx +++ b/src/components/FeeEstimator.tsx @@ -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 { @@ -32,6 +33,7 @@ export function FeeEstimator({ const [fee, setFee] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [containerRef, isVisible] = useIsVisible(); const load = useCallback(async () => { if (!client) return; @@ -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); @@ -68,7 +77,7 @@ export function FeeEstimator({ return () => { window.clearTimeout(timerId); }; - }, [load, refreshInterval]); + }, [load, refreshInterval, isVisible]); const compactContent = fee ? `Base: ${fee.baseFee} stroops · Recommended: ${fee.recommended} stroops` @@ -76,6 +85,7 @@ export function FeeEstimator({ return (
{ + beforeEach(() => { + MockIntersectionObserver.instances = []; + vi.stubGlobal("IntersectionObserver", MockIntersectionObserver); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("starts visible (optimistic) before the observer fires", () => { + const { result } = renderHook(() => useIsVisible()); + const [, isVisible] = result.current; + expect(isVisible).toBe(true); + }); + + it("does nothing until the ref is attached to a node", () => { + renderHook(() => useIsVisible()); + // 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(); + // 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(); + 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(); + 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(); + ref.current = node; + return isVisible; + }); + + expect(result.current).toBe(true); + }); +}); diff --git a/src/hooks/useIsVisible.ts b/src/hooks/useIsVisible.ts new file mode 100644 index 0000000..e6ebbed --- /dev/null +++ b/src/hooks/useIsVisible.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Tracks whether the element `ref` is attached to currently has a laid-out, + * non-zero-area box — i.e. it isn't `display: none` (directly, or via a + * `hidden` ancestor) and isn't scrolled fully out of any clipping + * container. Backed by `IntersectionObserver`, so this is layout-driven, + * not viewport-scroll-driven: an element under `hidden` reports `false` + * immediately, with no scrolling involved. + * + * Built for `Dashboard`'s "mount once, keep alive" screens (see + * `Dashboard.tsx`): a screen that isn't the active tab is still mounted + * (its form state and subscriptions survive navigating away) but is + * wrapped in an element with the `hidden` attribute. A component that + * polls — `FeeEstimator`, `ContractEventFeed` — uses this to pause that + * polling while its screen isn't the one showing, instead of either + * polling forever in the background or requiring `Dashboard` to thread an + * `isActive` prop through every screen and every component that polls. + * + * Returns `true` before the first observer callback fires (optimistic — + * assume visible until proven otherwise) so a component doesn't skip its + * very first load while waiting on the initial IntersectionObserver + * entry, which can arrive a frame or two after mount. + */ +export function useIsVisible(): [React.RefObject, boolean] { + const ref = useRef(null); + const [isVisible, setIsVisible] = useState(true); + + useEffect(() => { + const node = ref.current; + if (!node) return; + + // No IntersectionObserver (very old browser, or a non-DOM test + // environment that doesn't polyfill it) — fail open rather than + // silently never polling. + if (typeof IntersectionObserver === "undefined") return; + + const observer = new IntersectionObserver( + ([entry]) => { + if (entry) setIsVisible(entry.isIntersecting); + }, + // threshold: 0 — any non-zero intersection counts as visible; this + // is a mount-gate, not a "is it prominently on screen" measurement. + { threshold: 0 }, + ); + observer.observe(node); + return () => observer.disconnect(); + }, []); + + return [ref, isVisible]; +}