feat: cross-tab wallet session sync & auto-reconnect - #84
Conversation
- Add types/wallet-sync.ts with WalletSyncState, WalletSyncMessage, WalletSyncMessageType enum, and config/listener type definitions - Add utils/walletStorage.ts: type-safe localStorage read/write/clear with quota-exceeded handling, version counter, staleness check, and conflict resolution helper (isStateNewer) - Add utils/walletSyncManager.ts: WalletSyncManager class wrapping BroadcastChannel with localStorage+storage-event fallback, debounced broadcasts, immediate disconnect broadcast, unique tab ID generation, and singleton helpers (getWalletSyncManager / destroyWalletSyncManager) - Add hooks/useWalletSync.ts: React hook over WalletSyncManager that manages subscription lifecycle, exposes broadcastState, broadcastDisconnect, loadPersistedState, and getTabId - Refactor hooks/useWallet.ts: declare sync before useWalletSync to fix hoisting bug; route broadcastState through a stable ref to break the circular dependency; add mount-time persisted-state restore; broadcast on account/network/allowed changes; broadcast disconnect immediately - Export useWalletSync from hooks/index.ts - Update WalletButton: add syncedAcrossTabs pulse-ring badge, Synced label in dropdown, previousAddress account-switch notice banner, and onDismissSwitchNotice callback - Update ConnectButton: add isConnecting and disabled props; show 'Connecting in another tab...' when another tab is mid-connection - Update Navbar: track address changes with useRef to detect cross-tab account switches and pass syncedAcrossTabs + previousAddress to WalletButton; forward isBusy to ConnectButton as isConnecting - Update WalletData: forward isBusy to ConnectButton as isConnecting - Add utils/walletStorage.test.ts: tests for all storage helpers - Add utils/walletSyncManager.test.ts: tests for broadcast, debounce, disconnect, listener cleanup, singleton, and localStorage fallback - Add hooks/useWalletSync.test.ts: tests for subscription lifecycle, callbacks, broadcastState/Disconnect, loadPersistedState, cleanup - Extend hooks/useWallet.test.ts: mock useWalletSync and add 6 cross-tab integration tests (persist restore, newer/stale state, disconnect propagation, disconnect broadcast, account-change broadcast) - Update README.md architecture notes with cross-tab sync documentation
|
@Biokes is attempting to deploy a commit to the Meshack Yaro's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
A number of fixes to take care of below before this can be ready to approve and merge. Potential Considerations:
Possible Improvements
|
- Remove console.log calls from WalletSyncManager.initialize() — they were polluting test output on every BroadcastChannel setup - Remove console.warn/error from walletStorage read path — these fire on intentionally-triggered error branches in tests (corrupt JSON, invalid structure) and should be silent - Rewrite useWallet.test.ts timer handling: replace jest.advanceTimersByTime inside async act() with jest.runAllTimersAsync() which advances timers AND flushes the async promise chain produced by the interval callback, fixing the 5s timeouts and torn-down-environment errors that occurred when fake timers were still live after a test ended - Extract tickPolling() helper to reduce repetition across all timer tests - Move jest.useRealTimers() to afterEach before clearAllTimers so there is no window where fake timers are live but the Jest environment is already torn down - Remove unused waitFor import
- Remove console.log calls from WalletSyncManager.initialize() — they were polluting test output on every BroadcastChannel setup - Remove console.warn/error from walletStorage read path — these fire on intentionally-triggered error branches in tests (corrupt JSON, invalid structure) and should be silent - Rewrite useWallet.test.ts timer handling: replace jest.advanceTimersByTime inside async act() with jest.runAllTimersAsync() which advances timers AND flushes the async promise chain produced by the interval callback, fixing the 5s timeouts and torn-down-environment errors that occurred when fake timers were still live after a test ended - Extract tickPolling() helper to reduce repetition across all timer tests - Move jest.useRealTimers() to afterEach before clearAllTimers so there is no window where fake timers are live but the Jest environment is already torn down - Remove unused waitFor import
- useWallet.ts: check isStateStale(persistedState, 5000) in mount effect before applying persisted state; tabs reopened after a long gap no longer apply a stale snapshot that would win the version conflict check ahead of a fresher broadcast from an active tab. Always call sync() on mount regardless so Freighter is the source of truth in the stale case. - useWallet.ts: expand broadcastStateRef comment to document the unmount safety guarantee (pending broadcasts live inside the manager's debounce setTimeout, not inside the ref itself). - useWallet.ts: clarify isBusy JSDoc — true while setAllowed is pending or an immediate poll is running; use to disable the connect button and show a loading indicator. - useWallet.ts: import isStateStale alongside isStateNewer. - walletSyncManager.ts: change localStorage fallback broadcast key from 'trustflow-wallet-state' to 'trustflow-wallet-state:broadcast' so fallback channel messages no longer collide with the persistence key written by walletStorage.ts. - walletSyncManager.ts: remove all remaining console.log / console.warn / console.error calls so test output is clean. - useWalletSync.ts: document singleton behaviour and multi-instance safety in the hook JSDoc; recommend provider pattern for large apps. - useWallet.test.ts: add 'skips stale persisted state on mount' test covering the new staleness guard; update fresh-restore test comment. - README.md: document that the version counter persists across page refreshes and browser restarts (intentional, ensures monotonically increasing versions); document broadcast key separation.
- Downgrade @testing-library/jest-dom from ^7.0.1 to ^6.6.3. v7 requires Node >=22; CI runs on Node 18 and 20, which caused EBADENGINE warnings and would break jest-dom matchers at runtime. v6 supports Node >=18 and has identical APIs for our test suite. - Pin sharp to 0.33.5 (from ^0.35.2). v0.35.x requires Node >=20.9.0; v0.33.5 is the latest release that supports Node >=18.17.0, keeping compatibility with the Node 18 matrix leg. - Switch CI install step from 'npm ci' to 'npm install --prefer-offline'. The committed package-lock.json was missing the @swc/helpers@0.5.23 transitive dependency (pulled in by next@13), causing npm ci to abort with EUSAGE. npm install regenerates the lock file in-place on the runner and the setup-node cache step still provides fast installs on subsequent runs.
jest-util is a peer dependency of ts-jest but was only present nested under @jest/core/node_modules and not at the root. ts-jest resolves it from the root and fails with MODULE_NOT_FOUND when it is not hoisted. Adding it explicitly as a devDependency forces npm to hoist it. Also consolidate the engine-compatibility fixes that were previously committed but lost when package.json was reset: - @testing-library/jest-dom: ^7.0.1 -> ^6.6.3 (v7 requires Node >=22, CI runs Node 18 and 20) - sharp: ^0.35.2 -> 0.33.5 (v0.35.x requires Node >=20.9.0, v0.33.5 is the last release supporting Node 18)
…olling jest.runAllTimersAsync() exhaustively drains all timers including the ones each setInterval cycle re-queues, entering an infinite loop and hitting the 5000ms test timeout. The first test then leaves a dangling act() microtask loop that corrupts the React testing environment for every subsequent test, causing result.current to remain null. jest.advanceTimersByTimeAsync(2000) advances the clock by exactly one polling interval and awaits the async work produced by that single tick, then stops — matching the intended 'simulate one poll cycle' semantics without ever re-entering the interval.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
feat: Cross-Tab Wallet Session Sync & Auto-Reconnect
Summary
Implements full cross-tab synchronization of Freighter wallet state (account, network, allowed status) across browser tabs via
BroadcastChannelwith alocalStorage+storageevent fallback. Includes silent reconnection when the active Freighter account changes, conflict resolution using version numbers, and UI feedback for account switches detected from other tabs. No new runtime dependencies were added.New Files
types/wallet-sync.tsDefines all shared TypeScript interfaces:
WalletSyncState,WalletSyncMessage,WalletSyncMessageTypeenum,WalletSyncConfig,WalletSyncListener, andWalletDisconnectListener.utils/walletStorage.tsType-safe
localStoragepersistence layer. HandlesQuotaExceededErrorby clearing and retrying, returnsnulland clears on corrupt JSON, and is SSR-safe via anisStorageAvailable()guard. ExportsreadWalletState,writeWalletState,clearWalletState,getNextVersion,isStateStale, andisStateNewer.utils/walletSyncManager.tsWalletSyncManagerclass — the core of cross-tab communication. Opens aBroadcastChanneland falls back tolocalStorage+window.storageevents if unavailable. Debounces outgoing state broadcasts at 100ms. Disconnect broadcasts bypass debouncing and are always immediate. Each instance generates a unique tab ID viacrypto.randomUUID(). Messages from the same tab are filtered on receive. Exports a singleton viagetWalletSyncManager/destroyWalletSyncManager.hooks/useWalletSync.tsReact wrapper over
WalletSyncManager. Initializes on mount, cleans up on unmount. Stores callbacks in refs so subscription identity is stable across renders. ExposesbroadcastState,broadcastDisconnect,loadPersistedState, andgetTabId.utils/walletStorage.test.tsUnit tests for all storage helpers: read/write/clear, corrupt data handling,
QuotaExceededErrorretry, version counter increment and persistence, staleness check, andisStateNewerwith version and timestamp comparisons.utils/walletSyncManager.test.tsUnit tests with a
MockBroadcastChannel. Covers: unique tab IDs, idempotent initialize, debounce and immediate broadcast modes, self-message filtering, debounce collapsing to last state only, disconnect delivery and self-filtering, listener unsubscribe,loadPersistedState, singleton helpers, destroy clearing all listeners, andBroadcastChannelconstructor failure fallback.hooks/useWalletSync.test.tsUnit tests for the React hook:
enabled=falsesuppresses callbacks, incomingSTATE_UPDATEfiresonStateReceived, callback ref swap delivers to latest callback only,DISCONNECTfiresonDisconnectReceived,broadcastStatecalls the manager with the correct shape,broadcastDisconnectcalls the manager,loadPersistedStatereturns stored data,getTabIdreturns a non-empty string, and subscriptions are removed on unmount.Modified Files
hooks/useWallet.tssyncwas declared afteruseWalletSyncand the mount effect, so both calledvoid sync()againstundefinedon the first render. Fixed by declaringsyncfirst and routingbroadcastStatethrough abroadcastStateRefref updated each render — breaking the circular dependency without addingbroadcastStatetosync's dep array.localStorageand applies it immediately so there is no blank-wallet flash while the first Freighter poll completes.onStateReceivedfires with a newer state containing an account,sync()is called silently to validate that account is still accessible in Freighter.lastStateRefviaisStateNewer. Stale or equal states are discarded without touching React state.disconnect()now callsbroadcastDisconnect()immediately after clearing local state, propagating logout to all tabs.hooks/index.tsAdded
export * from "./useWalletSync".components/atoms/wallet-button/index.tsxsyncedAcrossTabsprop: renders a greenanimate-pingpulse ring on the connection indicator and a "Synced" label with a rotate-arrows icon inside the dropdown.previousAddressprop: renders an amber account-switch banner at the top of the dropdown showingold → newaddress, and auto-opens the dropdown so the user notices without clicking.onDismissSwitchNoticecallback: clears the notice when the user clicks away, toggles the dropdown, or clicks any menu item.components/atoms/connect-button/index.tsxisConnectingprop: when true, disables the button with a spinner and shows "Connecting in another tab…" to prevent duplicate Freighter permission popups.disabledprop for full parent-controlled disable.components/molecules/wallet-data/index.tsxForwards
isBusyfromuseWallettoConnectButtonasisConnecting.components/organisms/navbar/index.tsxprevAddressRef+useEffect. When address changes, stores the previous address inswitchedFromAddressstate.syncedAcrossTabs,previousAddress, andonDismissSwitchNoticetoWalletButton.isConnecting={isBusy && !walletError}toConnectButton, replacing the old inline status span.hooks/useWallet.test.tsjest.mock('./useWalletSync')that captures theonStateReceivedandonDisconnectReceivedcallbacks so tests can invoke them directly to simulate cross-tab messages.disconnect(), and account-change broadcast when Freighter switches accounts during polling.README.mdExtended Architecture Notes with a dedicated cross-tab sync subsection covering the flow, a file-role table, and the fallback strategy.
Architecture Decisions
BroadcastChannelas primary transportlocalStorage+storageevents as fallbackstorageevents only fire in other tabs. Covers older browsers and privacy-mode environmentsbroadcastStateRefto break circular depsyncneedsbroadcastState, butbroadcastStatecomes fromuseWalletSyncwhose options referencesync. A ref breaks the cycle without stale closuresBroadcastChannel,localStorage,crypto.randomUUID) are nativeManual Test Checklist
Closes #66