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
7 changes: 6 additions & 1 deletion docs/wiki/log.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Log
updated: 2026-07-10
updated: 2026-07-28
type: log
---

Expand Down Expand Up @@ -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.
3 changes: 2 additions & 1 deletion docs/wiki/progress.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Progress
updated: 2026-07-27
updated: 2026-07-28
type: ledger
---

Expand All @@ -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 |
Expand Down
3 changes: 2 additions & 1 deletion docs/wiki/project.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Project
updated: 2026-07-14
updated: 2026-07-28
type: context
---

Expand Down Expand Up @@ -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/<domain>/<feature>/` 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.
Expand Down
124 changes: 124 additions & 0 deletions src/hooks/tests/use-active-chain-switch.test.tsx
Original file line number Diff line number Diff line change
@@ -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()
})
})
54 changes: 54 additions & 0 deletions src/hooks/use-active-chain-switch.ts
Original file line number Diff line number Diff line change
@@ -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 })
}
Comment thread
lcamargof marked this conversation as resolved.
}, [enabled, isDocumentActive, targetChain, walletChain, switchChain])
}

export default useActiveChainSwitch
30 changes: 17 additions & 13 deletions src/hooks/useRTokenContext.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
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,
} from 'state/rtoken/atoms/rTokenAtom'
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]) {
Expand All @@ -27,15 +28,13 @@ 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(() => {
const listedToken = getListedRToken(tokenId as string, chainId)

return listedToken
}, [chain, tokenId])
}, [chainId, tokenId])

const setChain = useSetAtom(chainIdAtom)
const setRToken = useSetAtom(rTokenMetaAtom)
Expand Down Expand Up @@ -80,7 +79,7 @@ const useRTokenContext = () => {
})
setChain(chainId)
}
}, [rToken?.address])
}, [chainId, rToken, setChain, setRToken])

useEffect(() => {
if (unlistedToken.isFetched && !rToken) {
Expand All @@ -100,20 +99,25 @@ const useRTokenContext = () => {
navigate(ROUTES.NOT_FOUND)
}
}
}, [unlistedToken.status])
}, [
chainId,
navigate,
rToken,
setChain,
setRToken,
tokenId,
unlistedToken.data,
unlistedToken.isFetched,
])

useEffect(() => {
if (switchChain && chainId && selected && chainId !== walletChain) {
switchChain({ chainId })
}
}, [chainId, selected])
useActiveChainSwitch(chainId, !!selected)

// Cleanup RToken
useEffect(() => {
return () => {
setRToken(null)
}
}, [])
}, [setRToken])
}

export default useRTokenContext
18 changes: 3 additions & 15 deletions src/views/index-dtf/index-dtf-container.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -219,7 +207,7 @@ const Updater = () => {
const [key, setKey] = useState(0)
useIndexDTFTransactions(tokenAddress, chainId)

useChainWatch()
useActiveChainSwitch(chainId as AvailableChain)

const resetState = useCallback(() => {
// Remove duplicates
Expand Down
Loading