From 8b235aca9e0c7ecefbdda478a26c2f591d12300e Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 28 Jul 2026 11:18:41 -0700 Subject: [PATCH 1/2] chore: fix chain switching on multi tabs --- docs/wiki/log.md | 7 +- docs/wiki/progress.md | 3 +- docs/wiki/project.md | 3 +- .../tests/use-active-chain-switch.test.tsx | 124 ++++++++++++++++++ src/hooks/use-active-chain-switch.ts | 54 ++++++++ src/hooks/useRTokenContext.ts | 13 +- src/views/index-dtf/index-dtf-container.tsx | 18 +-- 7 files changed, 195 insertions(+), 27 deletions(-) create mode 100644 src/hooks/tests/use-active-chain-switch.test.tsx create mode 100644 src/hooks/use-active-chain-switch.ts diff --git a/docs/wiki/log.md b/docs/wiki/log.md index 2c69d4d92..844132385 100644 --- a/docs/wiki/log.md +++ b/docs/wiki/log.md @@ -1,6 +1,6 @@ --- title: Log -updated: 2026-07-10 +updated: 2026-07-28 type: log --- @@ -120,3 +120,8 @@ Play-by-play lives in git (PRs #1053/#1054/#1055/#1063, SDK PR #27). Durable out - Deeper auctions SDK migration deferred to protocol-vNext, riding its rebalance-testing window. - Comment discipline hard rule → `skills/code-standards.md`: one-line WHYs only, no finding/review IDs in source, agent warnings go to area-guide traps. - Remaining queue: `docs/plans/FOLLOWUPS.md`. + +## 2026-07-28 + +- Production chain cycling was a multi-tab feedback loop: each mounted DTF route automatically reasserted its own Ethereum/Base/BSC chain against one wallet-global network. Transaction buttons and the zapper only exposed the changing state. Automatic switching remains, but only the focused, visible document may request it; focusing another DTF tab makes that tab the new chain owner. Index and Yield DTF route contexts share the same guard. +- Review caught two smaller bounce paths before closeout: Index initially targeted the lagging global chain atom instead of its provider identity, and cached focus state was not revalidated at the wallet-mutation boundary. Both are now regression-protected; future route-chain synchronization must use the route/provider chain directly. diff --git a/docs/wiki/progress.md b/docs/wiki/progress.md index 2b2fc03d2..31b4dd56b 100644 --- a/docs/wiki/progress.md +++ b/docs/wiki/progress.md @@ -1,6 +1,6 @@ --- title: Progress -updated: 2026-07-27 +updated: 2026-07-28 type: ledger --- @@ -10,6 +10,7 @@ Stage ledger. One row per stage; keep entries short. Verifier = exact fresh comm | Stage | Status | Verifier | Review | Next | |---|---|---|---|---| +| focused-tab automatic chain switching | human-review-required (base b2fcf72c6) | lint/typecheck · unit 840 incl. 8 focus/visibility · helpers 70 · smoke 56 + 1 skipped · wiki-lint | Dark + Light: stale Index target + mutation-boundary focus recheck fixed; automatic switching preserved, background tabs passive | Engineer review wallet/chain flow; then ship | | vote-lock APR unified on /dtf/daos list | done (base 771c92873) | gate-equivalent green (lint/typecheck/unit, 70 helper, 56 smoke, wiki-lint) · live visual: BUILDOUT + POWER overviews and earn all 46.83% from one list request | Dark + Light, per-claim verify — adopted: list-miss/error fallback gating, plain-data return, catalog mixed-case normalization; API re-key REVERTED by Luis (sdk `getVoteLockDao` needs the DTF-address key); accepted: unlisted DTFs keep per-DTF detail cache split | pre-existing reserve-api debt flagged, not shipped: maxAge-before-await caches 500s 24h; unguarded `underlyingPrice.price` deref can 500 whole list | | merge master RFQ into feature hardening | human-review-required (base 0237e747c; merged `origin/master` 981b634d7; observed CLS explicitly out of scope) | frozen install · scoped gate-equivalent: lint/typecheck, 832 unit, 70 helper, 56 smoke + 1 known fixme · zap 21 · production build · wiki-lint pending | Dark + Light pass; SDK 0.5.0 pin + zapper 2.7.1 reconciled; hardening guards preserved; engineer review required for live RFQ/intent execution | commit merge locally; do not push | | Hardening × SDK-integration effort — sanitizer/e2e foundation · overview SDK adoption + portfolio governance-state adoption · crash + money-display guards · rebalance launch guards | **ready for master PR** (stacked PRs merged; SDK 0.5.0 pinned exact; master merged 2026-07-23) | typecheck 0 · unit green · smoke 56 · full flows green · wiki-lint green; guards revert-verified at their seams | cross-reviews reconciled in docs/claude-hardening-review.md; release blockers fixed + pinned (malformed portfolio rows incl. optimistic context, manage-weights fail-closed, zap max) | master PR on go; queue in docs/plans/FOLLOWUPS.md | diff --git a/docs/wiki/project.md b/docs/wiki/project.md index c8476b2bb..63de13199 100644 --- a/docs/wiki/project.md +++ b/docs/wiki/project.md @@ -1,6 +1,6 @@ --- title: Project -updated: 2026-07-14 +updated: 2026-07-28 type: context --- @@ -51,6 +51,7 @@ Register — the web interface for Reserve Protocol: **Index DTFs** (current foc - **Live state → RPC, not subgraph.** Basket balances, live proposal state, live rebalance/auction state come from RPC. Subgraph = metadata/history only. - **Money is `Amount`/`bigint`.** Never `Number` for on-chain math; convert only at display leaves. Keep SDK `Amount` objects intact through atoms and logic. - **Feature isolation.** One feature = one folder under `views///` owning its `components/`, `hooks/`, `atoms.ts`, `utils.ts`. Shared code never imports from a feature; features never reach into each other's internals. Fix local bugs locally — never via shared containers, providers, routing shells, or component defaults. +- **Automatic DTF chain switching is focus-owned.** Only the focused, visible document may synchronize the wallet to its route chain; background tabs stay passive. The route/provider identity is the target — never a lagging global chain atom. - **Shared components keep their defaults** (`DataTable`, legacy `Table`, …) — behavior via opt-in props only. - **Design tokens only** — no hardcoded hex/hsl anywhere; see [[design-system]]. - **e2e suite health is part of every stage.** Register work is agent-driven and the offline suite ([[e2e]]) is the contract that makes that safe. Stages touching covered surfaces update mocks/snapshots as part of the stage (fail-loud misses are work, not noise), never leave a new `test.fixme` without a tracked owner, and keep re-capture cheap. A red or routed-around e2e is a blocker. diff --git a/src/hooks/tests/use-active-chain-switch.test.tsx b/src/hooks/tests/use-active-chain-switch.test.tsx new file mode 100644 index 000000000..b2e50d33e --- /dev/null +++ b/src/hooks/tests/use-active-chain-switch.test.tsx @@ -0,0 +1,124 @@ +import { act, renderHook } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import useActiveChainSwitch from '../use-active-chain-switch' + +const mocks = vi.hoisted(() => ({ + switchChain: vi.fn(), + walletChain: 56 as number | undefined, +})) + +vi.mock('jotai', () => ({ + useAtomValue: () => mocks.walletChain, +})) + +vi.mock('@/state/atoms', () => ({ + walletChainAtom: Symbol('walletChainAtom'), +})) + +vi.mock('wagmi', () => ({ + useSwitchChain: () => ({ switchChain: mocks.switchChain }), +})) + +describe('useActiveChainSwitch', () => { + let hasFocus = true + let visibilityState: DocumentVisibilityState = 'visible' + + beforeEach(() => { + mocks.switchChain.mockReset() + mocks.walletChain = 56 + hasFocus = true + visibilityState = 'visible' + vi.spyOn(document, 'hasFocus').mockImplementation(() => hasFocus) + vi.spyOn(document, 'visibilityState', 'get').mockImplementation( + () => visibilityState + ) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('switches to the target chain when the document is focused and visible', () => { + renderHook(() => useActiveChainSwitch(1)) + + expect(mocks.switchChain).toHaveBeenCalledOnce() + expect(mocks.switchChain).toHaveBeenCalledWith({ chainId: 1 }) + }) + + it('does not switch from a background tab', () => { + hasFocus = false + + renderHook(() => useActiveChainSwitch(1)) + + expect(mocks.switchChain).not.toHaveBeenCalled() + }) + + it('does not switch from a hidden document that still reports focus', () => { + visibilityState = 'hidden' + + renderHook(() => useActiveChainSwitch(1)) + + expect(mocks.switchChain).not.toHaveBeenCalled() + }) + + it('claims the target chain when a background tab becomes active', () => { + hasFocus = false + const { rerender } = renderHook(() => useActiveChainSwitch(1)) + expect(mocks.switchChain).not.toHaveBeenCalled() + + hasFocus = true + act(() => { + window.dispatchEvent(new Event('focus')) + }) + rerender() + + expect(mocks.switchChain).toHaveBeenCalledOnce() + expect(mocks.switchChain).toHaveBeenCalledWith({ chainId: 1 }) + }) + + it('claims the target chain when a focused document becomes visible', () => { + visibilityState = 'hidden' + renderHook(() => useActiveChainSwitch(1)) + expect(mocks.switchChain).not.toHaveBeenCalled() + + visibilityState = 'visible' + act(() => { + document.dispatchEvent(new Event('visibilitychange')) + }) + + expect(mocks.switchChain).toHaveBeenCalledOnce() + expect(mocks.switchChain).toHaveBeenCalledWith({ chainId: 1 }) + }) + + it('ignores wallet-chain changes after the tab moves to the background', () => { + mocks.walletChain = 1 + const { rerender } = renderHook(() => useActiveChainSwitch(1)) + + hasFocus = false + act(() => { + window.dispatchEvent(new Event('blur')) + }) + + mocks.walletChain = 56 + rerender() + + expect(mocks.switchChain).not.toHaveBeenCalled() + }) + + it('rechecks focus before switching when the cached active state is stale', () => { + mocks.walletChain = 1 + const { rerender } = renderHook(() => useActiveChainSwitch(1)) + + hasFocus = false + mocks.walletChain = 56 + rerender() + + expect(mocks.switchChain).not.toHaveBeenCalled() + }) + + it('does not switch when disabled', () => { + renderHook(() => useActiveChainSwitch(1, false)) + + expect(mocks.switchChain).not.toHaveBeenCalled() + }) +}) diff --git a/src/hooks/use-active-chain-switch.ts b/src/hooks/use-active-chain-switch.ts new file mode 100644 index 000000000..20a402517 --- /dev/null +++ b/src/hooks/use-active-chain-switch.ts @@ -0,0 +1,54 @@ +import { walletChainAtom } from '@/state/atoms' +import type { AvailableChain } from '@/utils/chains' +import { useAtomValue } from 'jotai' +import { useEffect, useState } from 'react' +import { useSwitchChain } from 'wagmi' + +const getIsDocumentActive = () => + typeof document !== 'undefined' && + document.visibilityState === 'visible' && + document.hasFocus() + +const useIsDocumentActive = () => { + const [isActive, setIsActive] = useState(getIsDocumentActive) + + useEffect(() => { + const update = () => setIsActive(getIsDocumentActive()) + + window.addEventListener('focus', update) + window.addEventListener('blur', update) + document.addEventListener('visibilitychange', update) + + return () => { + window.removeEventListener('focus', update) + window.removeEventListener('blur', update) + document.removeEventListener('visibilitychange', update) + } + }, []) + + return isActive +} + +const useActiveChainSwitch = ( + targetChain: AvailableChain | undefined, + enabled = true +) => { + const { switchChain } = useSwitchChain() + const walletChain = useAtomValue(walletChainAtom) + const isDocumentActive = useIsDocumentActive() + + useEffect(() => { + if ( + enabled && + isDocumentActive && + getIsDocumentActive() && + targetChain && + walletChain && + targetChain !== walletChain + ) { + switchChain({ chainId: targetChain }) + } + }, [enabled, isDocumentActive, targetChain, walletChain, switchChain]) +} + +export default useActiveChainSwitch diff --git a/src/hooks/useRTokenContext.ts b/src/hooks/useRTokenContext.ts index cf26d6ce8..506682d5b 100644 --- a/src/hooks/useRTokenContext.ts +++ b/src/hooks/useRTokenContext.ts @@ -1,9 +1,10 @@ import rtokens from '@reserve-protocol/rtokens' import RToken from 'abis/RToken' +import useActiveChainSwitch from 'hooks/use-active-chain-switch' import { useAtomValue, useSetAtom } from 'jotai' import { useEffect, useMemo } from 'react' import { useNavigate, useParams } from 'react-router-dom' -import { chainIdAtom, walletChainAtom } from 'state/atoms' +import { chainIdAtom } from 'state/atoms' import { rTokenMetaAtom, selectedRTokenAtom, @@ -11,7 +12,7 @@ import { import { AvailableChain } from 'utils/chains' import { NETWORKS, ROUTES } from 'utils/constants' import { Address, getAddress } from 'viem' -import { useReadContracts, useSwitchChain } from 'wagmi' +import { useReadContracts } from 'wagmi' const getListedRToken = (tokenId: string, chainId: number) => { if (!tokenId || !rtokens[chainId]) { @@ -27,8 +28,6 @@ const getListedRToken = (tokenId: string, chainId: number) => { const useRTokenContext = () => { const navigate = useNavigate() - const { switchChain } = useSwitchChain() - const walletChain = useAtomValue(walletChainAtom) const { chain, tokenId } = useParams() const chainId = NETWORKS[chain ?? ''] as AvailableChain const rToken = useMemo(() => { @@ -102,11 +101,7 @@ const useRTokenContext = () => { } }, [unlistedToken.status]) - useEffect(() => { - if (switchChain && chainId && selected && chainId !== walletChain) { - switchChain({ chainId }) - } - }, [chainId, selected]) + useActiveChainSwitch(chainId, !!selected) // Cleanup RToken useEffect(() => { diff --git a/src/views/index-dtf/index-dtf-container.tsx b/src/views/index-dtf/index-dtf-container.tsx index 911e7040c..e0b900f8f 100644 --- a/src/views/index-dtf/index-dtf-container.tsx +++ b/src/views/index-dtf/index-dtf-container.tsx @@ -1,7 +1,8 @@ import SEO from '@/components/seo' import useFavicon from '@/hooks/useFavicon' +import useActiveChainSwitch from '@/hooks/use-active-chain-switch' import useIndexDTFTransactions from '@/hooks/useIndexDTFTransactions' -import { chainIdAtom, walletChainAtom } from '@/state/atoms' +import { chainIdAtom } from '@/state/atoms' import { indexDTFAtom, indexDTFBasketAmountsAtom, @@ -41,7 +42,6 @@ import { atom, useAtom, useAtomValue, useSetAtom } from 'jotai' import { useCallback, useEffect, useLayoutEffect, useState } from 'react' import { Outlet, useNavigate, useParams, useLocation } from 'react-router-dom' import { Address } from 'viem' -import { useSwitchChain } from 'wagmi' import IndexDTFNavigation from './components/navigation' import ConfirmEligibilityModal from './components/confirm-eligibility-modal' import GovernanceUpdater from './governance/updater' @@ -109,18 +109,6 @@ const IndexDTFSEO = () => { ) } -const useChainWatch = () => { - const { switchChain } = useSwitchChain() - const walletChain = useAtomValue(walletChainAtom) - const chainId = useAtomValue(chainIdAtom) - - useEffect(() => { - if (chainId !== walletChain && walletChain) { - switchChain({ chainId }) - } - }, [chainId, walletChain, switchChain]) -} - // The atoms stay because each SDK read has several consumers that can't take the hook directly yet. const IndexDtfUpdaters = () => { const identity = useIndexDtfIdentity() @@ -219,7 +207,7 @@ const Updater = () => { const [key, setKey] = useState(0) useIndexDTFTransactions(tokenAddress, chainId) - useChainWatch() + useActiveChainSwitch(chainId as AvailableChain) const resetState = useCallback(() => { // Remove duplicates From 3c8f1aa202b2a0c0e9316ea6f2242537f418a0c1 Mon Sep 17 00:00:00 2001 From: Luis Date: Tue, 28 Jul 2026 11:26:45 -0700 Subject: [PATCH 2/2] chore: lint fix --- src/hooks/useRTokenContext.ts | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/src/hooks/useRTokenContext.ts b/src/hooks/useRTokenContext.ts index 506682d5b..8a58b0921 100644 --- a/src/hooks/useRTokenContext.ts +++ b/src/hooks/useRTokenContext.ts @@ -34,7 +34,7 @@ const useRTokenContext = () => { const listedToken = getListedRToken(tokenId as string, chainId) return listedToken - }, [chain, tokenId]) + }, [chainId, tokenId]) const setChain = useSetAtom(chainIdAtom) const setRToken = useSetAtom(rTokenMetaAtom) @@ -79,7 +79,7 @@ const useRTokenContext = () => { }) setChain(chainId) } - }, [rToken?.address]) + }, [chainId, rToken, setChain, setRToken]) useEffect(() => { if (unlistedToken.isFetched && !rToken) { @@ -99,7 +99,16 @@ const useRTokenContext = () => { navigate(ROUTES.NOT_FOUND) } } - }, [unlistedToken.status]) + }, [ + chainId, + navigate, + rToken, + setChain, + setRToken, + tokenId, + unlistedToken.data, + unlistedToken.isFetched, + ]) useActiveChainSwitch(chainId, !!selected) @@ -108,7 +117,7 @@ const useRTokenContext = () => { return () => { setRToken(null) } - }, []) + }, [setRToken]) } export default useRTokenContext