Skip to content

feat: cross-tab wallet session sync & auto-reconnect - #84

Merged
meshackyaro merged 12 commits into
trustflow-protocol:mainfrom
Biokes:fix/cross-tab
Aug 28, 2026
Merged

feat: cross-tab wallet session sync & auto-reconnect#84
meshackyaro merged 12 commits into
trustflow-protocol:mainfrom
Biokes:fix/cross-tab

Conversation

@Biokes

@Biokes Biokes commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

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 BroadcastChannel with a localStorage + storage event 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.ts

Defines all shared TypeScript interfaces: WalletSyncState, WalletSyncMessage, WalletSyncMessageType enum, WalletSyncConfig, WalletSyncListener, and WalletDisconnectListener.

utils/walletStorage.ts

Type-safe localStorage persistence layer. Handles QuotaExceededError by clearing and retrying, returns null and clears on corrupt JSON, and is SSR-safe via an isStorageAvailable() guard. Exports readWalletState, writeWalletState, clearWalletState, getNextVersion, isStateStale, and isStateNewer.

utils/walletSyncManager.ts

WalletSyncManager class — the core of cross-tab communication. Opens a BroadcastChannel and falls back to localStorage + window.storage events if unavailable. Debounces outgoing state broadcasts at 100ms. Disconnect broadcasts bypass debouncing and are always immediate. Each instance generates a unique tab ID via crypto.randomUUID(). Messages from the same tab are filtered on receive. Exports a singleton via getWalletSyncManager / destroyWalletSyncManager.

hooks/useWalletSync.ts

React wrapper over WalletSyncManager. Initializes on mount, cleans up on unmount. Stores callbacks in refs so subscription identity is stable across renders. Exposes broadcastState, broadcastDisconnect, loadPersistedState, and getTabId.

utils/walletStorage.test.ts

Unit tests for all storage helpers: read/write/clear, corrupt data handling, QuotaExceededError retry, version counter increment and persistence, staleness check, and isStateNewer with version and timestamp comparisons.

utils/walletSyncManager.test.ts

Unit 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, and BroadcastChannel constructor failure fallback.

hooks/useWalletSync.test.ts

Unit tests for the React hook: enabled=false suppresses callbacks, incoming STATE_UPDATE fires onStateReceived, callback ref swap delivers to latest callback only, DISCONNECT fires onDisconnectReceived, broadcastState calls the manager with the correct shape, broadcastDisconnect calls the manager, loadPersistedState returns stored data, getTabId returns a non-empty string, and subscriptions are removed on unmount.


Modified Files

hooks/useWallet.ts

  • Hoisting bug fixed: sync was declared after useWalletSync and the mount effect, so both called void sync() against undefined on the first render. Fixed by declaring sync first and routing broadcastState through a broadcastStateRef ref updated each render — breaking the circular dependency without adding broadcastState to sync's dep array.
  • Mount restore: On mount, reads the last persisted state from localStorage and applies it immediately so there is no blank-wallet flash while the first Freighter poll completes.
  • State-change detection: Each poll cycle detects whether account, network, or allowed status changed and only broadcasts when something actually changed. Broadcast is skipped when processing an incoming sync to prevent echo loops.
  • Silent reconnection: When onStateReceived fires with a newer state containing an account, sync() is called silently to validate that account is still accessible in Freighter.
  • Conflict resolution: Incoming states are compared against lastStateRef via isStateNewer. Stale or equal states are discarded without touching React state.
  • Disconnect broadcast: disconnect() now calls broadcastDisconnect() immediately after clearing local state, propagating logout to all tabs.

hooks/index.ts

Added export * from "./useWalletSync".

components/atoms/wallet-button/index.tsx

  • Added syncedAcrossTabs prop: renders a green animate-ping pulse ring on the connection indicator and a "Synced" label with a rotate-arrows icon inside the dropdown.
  • Added previousAddress prop: renders an amber account-switch banner at the top of the dropdown showing old → new address, and auto-opens the dropdown so the user notices without clicking.
  • Added onDismissSwitchNotice callback: clears the notice when the user clicks away, toggles the dropdown, or clicks any menu item.

components/atoms/connect-button/index.tsx

  • Added isConnecting prop: when true, disables the button with a spinner and shows "Connecting in another tab…" to prevent duplicate Freighter permission popups.
  • Added disabled prop for full parent-controlled disable.

components/molecules/wallet-data/index.tsx

Forwards isBusy from useWallet to ConnectButton as isConnecting.

components/organisms/navbar/index.tsx

  • Tracks address changes with prevAddressRef + useEffect. When address changes, stores the previous address in switchedFromAddress state.
  • Passes syncedAcrossTabs, previousAddress, and onDismissSwitchNotice to WalletButton.
  • Passes isConnecting={isBusy && !walletError} to ConnectButton, replacing the old inline status span.

hooks/useWallet.test.ts

  • Added jest.mock('./useWalletSync') that captures the onStateReceived and onDisconnectReceived callbacks so tests can invoke them directly to simulate cross-tab messages.
  • Added 6 new integration tests: persisted state restore on mount, newer incoming state applied, stale incoming state ignored, disconnect from another tab propagated, disconnect broadcast on local disconnect(), and account-change broadcast when Freighter switches accounts during polling.

README.md

Extended Architecture Notes with a dedicated cross-tab sync subsection covering the flow, a file-role table, and the fallback strategy.


Architecture Decisions

Decision Rationale
BroadcastChannel as primary transport Native, efficient, does not fire on the originating tab, broadly supported in modern browsers
localStorage + storage events as fallback storage events only fire in other tabs. Covers older browsers and privacy-mode environments
Version counter + timestamp tiebreaker Simple and deterministic. Higher version always wins; timestamp only breaks ties when versions are equal
broadcastStateRef to break circular dep sync needs broadcastState, but broadcastState comes from useWalletSync whose options reference sync. A ref breaks the cycle without stale closures
Disconnect is immediate, state updates are debounced Logout must propagate instantly for security. State updates are debounced at 100ms to avoid flooding during rapid polling
No new npm dependencies Bundle unchanged, no supply-chain risk. All primitives (BroadcastChannel, localStorage, crypto.randomUUID) are native

Manual Test Checklist

  • Open 3 tabs, connect wallet in Tab 1 — all tabs show connected
  • Switch account in Freighter — all tabs update, account-switch notice appears
  • Switch network in Freighter — all tabs update network label
  • Disconnect in Tab 2 — all tabs clear to connect state
  • Refresh Tab 3 while connected — state restores from localStorage before first poll
  • Close Tab 1, continue using Tab 2 — no errors
  • Test with Freighter not installed — no crashes, connect button shown

Closes #66

- 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
@vercel

vercel Bot commented Aug 25, 2026

Copy link
Copy Markdown

@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.

@meshackyaro

meshackyaro commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

A number of fixes to take care of below before this can be ready to approve and merge.

Potential Considerations:

  • Failing CI.

  • Persisted state and stale timestamp: When a tab is closed for a long time and reopened, loadPersistedState() applies state without checking isStateStale(). If another tab is still running and has broadcast a much fresher state in the meantime, the old persisted state may take precedence on reopen. Consider checking staleness in the mount effect before applying persisted state: if (persistedState && !isStateStale(persistedState, 5000)) { ... }

  • broadcastStateRef circular logic: While the ref breaks the hoisting bug, it creates a subtle pattern where sync() can only fire broadcasts through a ref that gets updated each render. If a component unmounts before the ref is updated, any pending broadcast will be missed. Document this or consider a cleanup pattern.

  • Multiple useWalletSync instances: If multiple components each call useWalletSync() independently, they each subscribe separately to the singleton manager. This is fine but increases listener count. No harm, but consider documenting that only one call per app is needed, or move the hook to a provider.

  • Fallback storage key collision: The localStorage fallback uses trustflow-wallet-state as a message key, which could theoretically conflict if another code path writes to that key. Consider namespacing as trustflow-wallet-state:broadcast or similar to avoid accidental overwrites.

Possible Improvements

  • isBusy state naming: The useWallet hook returns isBusy but it's primarily used to disable the connect button when a connection is in progress. Renaming to isConnecting or adding a boolean prop comment ("true while setAllowed or polling is active") would clarify intent.

  • README Architecture section: The new subsection is clear and helpful. One small addition: note that the version counter is stored in localStorage and persists across page refreshes, so the version can grow across sessions. This is intentional but worth documenting.

  • localStorage quota exceeded logging: When QuotaExceededError occurs and a retry is attempted, logging "cleared old data" could be confusing if only wallet state is being cleared (user may think all localStorage was wiped). Consider: console.warn('[walletStorage] Quota exceeded, cleared wallet state to retry')

  • Mock BroadcastChannel reset in tests: In walletSyncManager.test.ts, you have a MockBroadcastChannel.reset() call in beforeEach. This is good, but consider also adding a check in the mock constructor to warn if instances are being created after destroy (can catch test leaks early).

Biokes added 11 commits August 25, 2026 16:46
- 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.
@vercel

vercel Bot commented Aug 28, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
trustflow-frontend Ready Ready Preview Aug 28, 2026 8:08pm

@meshackyaro meshackyaro left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work on this PR — it's a thorough implementation of cross-tab wallet sync.

Overall this looks mergeable. Nice attention to detail on UX feedback (the switch notice, synced indicator) — that's often skipped in wallet integrations.

@meshackyaro
meshackyaro merged commit ae7f143 into trustflow-protocol:main Aug 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Cross-Tab Wallet Session Sync & Auto-Reconnect

2 participants