Skip to content

fix(widget-provider-ethereum): keep wagmi chain events across chain refetches (JUMEMB-192) - #934

Open
chybisov wants to merge 9 commits into
mainfrom
fix/jumemb-192-sync-wagmi-config
Open

chybisov wants to merge 9 commits into
mainfrom
fix/jumemb-192-sync-wagmi-config

Conversation

@chybisov

@chybisov chybisov commented Nov 12, 2026 •

Copy link
Copy Markdown
Member

Which Linear task is linked to this PR?

JUMEMB-192

SDK side: lifinance/sdk#540. This PR removes the cause in the widget, so wagmi does not keep an old chain.

What was wrong

  • useSyncWagmiConfig runs syncWagmiConfig each time the chains array changes. The /v1/chains response contains token prices (nativeToken.priceUSD), so every chains refetch (every 5 minutes) gives a new array.
  • syncWagmiConfig then made new connector objects with setup and called reconnect.
  • MetaMask Connect, WalletConnect, Coinbase, Base Account and the Farcaster mini-app connector bind their event handlers one time, to the first connector object. After a new setup, they send events to an emitter that wagmi no longer reads.
  • So wagmi did not see chain, account or disconnect events until the next sync. The wagmi chain stayed old. Results:
    • A batched swap went out with the old chain id (JUMPER-FRONT-8Z), and an approval failed with ChainMismatchError (JUMPER-FRONT-CH).
    • The UI showed the old chain, and reads with no chainId went to the old chain.

Jumper calls the same hook through @jumperexchange/widget-provider-ethereum and shares the chains query, so it has the same problem. 10 Jumper session replays with 8Z show a /chains request every 300 seconds, and "Got" is the wallet chain at the last /chains request.

What this PR changes

  1. One connector object for each connector. For each wagmi config, syncWagmiConfig sets up each connector function and each EIP-6963 wallet (by info.uuid) one time, and keeps the object. A connector SDK that binds its event handlers one time then always sends its events to the object that wagmi reads.
  2. A live chain list. wagmi gives each connector a copy of the chain list at setup. The connector now gets a copy of the setup parameter with a live chains getter. So a new chain from the API works without a new setup.
  3. reconnect only when it is necessary. A chains refetch only sets the wagmi chains. The sync runs reconnect in these cases only:
    • the first sync;
    • the connector list changed (a new connector function, a new EIP-6963 wallet, or a connector that the sync did not make, which it replaces);
    • wagmi lost the current connection (wagmi Hydrate clears the connections on each render when ssr is false, storage is set and reconnectOnMount is false).
  4. A queue for reconnect. wagmi drops a reconnect call while another one runs. The sync waits for its earlier call, so a connector that is added during the first reconnect (the Jumper mini-app) is connected too.
  5. One wagmi listener for each connector. wagmi reconnect adds its change and disconnect listeners again each time it connects the same connector object. After each reconnect of the sync, the connected connectors get exactly one of each again. The cache keeps connector functions in a WeakMap, so a connector function that a caller no longer uses can be freed.
  6. Tests. widget-provider-ethereum gets vitest and 18 tests.

Why was it implemented this way?

  • The fix is in the shared syncWagmiConfig, so the widget and Jumper both get it.
  • The cause is new connector objects. This version does not make them again, so it does not need to send events from old objects to new objects.
  • Three independent checks:
    • Blind design. An agent that saw only the problem and main designed the fix from scratch. It reached the same core: one object for each connector, a live chain list, a reconnect queue, and one object for each EIP-6963 wallet. This PR uses its descriptor copy in place of a Proxy.
    • Adversarial review. An agent tried to break the previous version of this PR. It proved three defects with tests. All three came from the removed reconnect after each refetch, which on main hid gaps in the wagmi state. Parts 3 and 4 above fix them, and the three tests pass now. A second round on the fixed code found two smaller issues, also proved with tests: each heal reconnect added one more wagmi listener to the same connector, and the cache kept old connector functions in memory. Both are fixed (part 5).
    • Upstream. wagmi has no supported way to change the chains at runtime. The maintainers call config._internal.chains.setState an unsupported workaround. No upstream issue or fix exists for the handlers that are bound to the first object.
  • Earlier versions of this PR:
    • A key of the chain fields that skipped a price-only sync, plus a wrapper that sent the events of each connector to its newest object. It worked, but it had duplicate and dropped events, and a real change still made new objects.
    • One object for each connector with no reconnect after a refetch. It had the three defects above.
  • The file is main plus 83 lines.
  • The connectors that the widget and Jumper use work with the live chain list:
    • The @wagmi/connectors connectors, the wagmi injected connector (also for EIP-6963 wallets) and the Farcaster mini-app connector read config.chains in each call.
    • The Abstract (AGW) connector copies the chain list at setup. It needs only the Abstract chain, so this has no effect.
  • Alternatives I did not use:
    • Only skip a price-only sync. A real change (a new chain from the API, or Jumper's Base mini-app connectors) still makes new connector objects. wagmi then stays on an old chain for the rest of the session.
    • Keep reconnect after each refetch. Each reconnect of the same connector object adds one more wagmi change listener (I measured 1, 2, 3, 4), so the listeners grow in a long session.
  • Limits:
    • An app that gives the same connector functions to createConfig still gets a second connector object at the first sync. The widget and Jumper give createConfig no connectors. widget-embedded does, and widgetLightConnector already handles this case.
    • A wallet that announces itself through EIP-6963 after a sync gets its connector from wagmi, with the chain list of that moment. The next sync replaces it. On main this was the same.
    • The wallet SDKs read the chain list one time, when they start. This is the same on main.
    • The queue orders only the reconnect calls of the sync. The widget and Jumper use reconnectOnMount={false}, so no other reconnect runs at that time.
  • Risk: the setup parameter copy depends on how wagmi gives the setup parameter to the connector function. The tests use the real wagmi createConfig, so a wagmi change that breaks this makes them fail.
  • Follow-ups, not in this PR (all behave the same on main):
    • The sync replaces connectors that another library adds to the wagmi store. In the playground with Reown, it removes the AppKit connectors at the next sync. A fix: replace only the connectors that the sync owns.
    • Set isNewChainsStale: false on WalletConnect. Without it, a chain that the API adds ends the WalletConnect session at the next reconnect or page load.
    • The injected io.metamask connector stays next to metaMaskSDK.
    • An upstream wagmi PR: give chains to connectors as a getter, as providers already is. Then the copy of the setup parameter is not necessary.

Tests

  • Unit tests use the real wagmi createConfig and a connector stub that binds its handlers one time, as MetaMask Connect does:

    • A price-only refetch runs no setup and no reconnect. After it, a chain switch by the dApp and a chain switch in the wallet both reach the wagmi state.
    • After many refetches, the connector object has 1 change listener.
    • A new chain, another chain order or a new RPC URL updates the wagmi chains with no setup and no reconnect.
    • A switch to a chain that the API adds after setup works, and a later switch in the wallet reaches wagmi.
    • An EIP-6963 wallet keeps its connector object and can switch to a chain that the API adds after setup.
    • An EIP-6963 connector that wagmi set up itself is replaced at the next sync and gets the live chain list.
    • When a connector function is added, only the new one is set up. The old connector object keeps its uid, its connection and its events.
    • A connector that is added while a reconnect runs is connected.
    • When wagmi Hydrate clears the connections, the next sync connects again. After 3 heals, the connector has 1 change and 1 disconnect listener.
    • A connector that spreads its setup parameter still gets the emitter.
    • The first call with no connectors sets the chains and runs reconnect. The playground passes [].
    • If a setup fails, the next call sets up only the connector that failed.
    • Two wagmi configs that share a connector function: the events go to the config that set it up first.
  • On main, 13 of the 18 tests fail. On the previous version of this PR, the 3 tests for the review defects fail.

  • When I remove each part, only its own tests fail: the queue, the heal, the listener reset, the live chain list, the EIP-6963 reuse, and a reconnect on each sync.

  • The tests of the adversarial review pass on this code: D1–D3 (also in a React render with WagmiProvider and StrictMode), the listener test, and a garbage-collection test for the cache. The 74 tests of the blind design pass too.

  • A Node script with the real wagmi createConfig, switchChain and getConnectorClient, and the real metaMask() connector. Only @metamask/connect-evm is a stub. The wallet starts on Polygon. The table shows the chain of getConnectorClient({ assertChainId: false }), which the widget uses for the next swap:

    Step Expected main This PR
    A chains refetch, then the swap switches to Base Base Polygon (8Z) Base
    A switch to Arbitrum in the wallet Arbitrum Polygon Arbitrum
    The API adds OP Mainnet, then the swap switches to it OP Mainnet Arbitrum OP Mainnet
    Then a switch to Base in the wallet Base Arbitrum Base
    A connector is added, then a switch to Polygon in the wallet Polygon Base Polygon
  • Playground in Chromium, with the real API and a temporary log, for 11 minutes, two times: with no wallet connector, and with MetaMask as the recent wallet, so the real metaMask() connector is set up. The chains refetches at 302 and 602 seconds returned new prices. Each time, syncWagmiConfig only updated the chains: no setup and no reconnect. The metaMaskSDK connector was set up one time, at the first sync. (This ran on the previous version. The refetch path is the same in this version.)

  • pnpm test (all packages), Oxlint, Oxfmt, knip and the type check pass.

  • Not tested yet: a real wallet. Planned with release-preview builds:

    1. MetaMask extension: switch the chain after more than 5 minutes.
    2. A mobile wallet through WalletConnect: put the app in the background, switch the chain, and come back after more than 5 minutes.
    3. The Jumper mini-app: check that the wallet connects automatically.

After merge

  • The same change for Jumper is in jumperexchange/jumper-widget#203, because the two repos are not easy to merge into each other.

Checklist before requesting a review

  • I have performed a self-review and testing of my code.
  • This pull request is focused and addresses a single problem.
  • If this PR modifies the Widget API or adds new features that require documentation, I have updated the documentation in the public-docs repository. (Not necessary: the API does not change.)

…efetches (JUMEMB-192)

The chains response includes token prices, so every chains refetch gave
a new chains array and syncWagmiConfig set up all connectors again and
reconnected. Connectors whose SDK binds its event handlers to the first
connector object (MetaMask Connect, WalletConnect, Coinbase, Base
Account) then emitted on an emitter that wagmi no longer reads, so the
wagmi chain went stale until the next refetch.

syncWagmiConfig now skips setup and reconnect when the chain fields
that wagmi and connectors read, and the connectors array, are the same
as at the last sync. When they do change, each connector function gets
a stable emitter that forwards to the newest connector object.

Adds vitest to the package with tests for both parts.
@changeset-bot

changeset-bot Bot commented Nov 12, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: ec4ba2e

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 10 packages
Name Type
@lifi/widget-provider-ethereum Patch
connectkit Patch
dynamic Patch
nft-checkout Patch
privy-ethers-example Patch
privy Patch
rainbowkit Patch
reown Patch
vite-iframe Patch
vite-project Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

📦 Bundle size

✅ No significant changes. Every check is within budget.

@lifi/widget

Import Base PR Change Budget used
⚪ LiFiWidget (without translations) 277.2 kB 277.2 kB — ▰▰▰▰▰▰▰▰▰▱ 86%
⚪ English translations (always loaded) 7.44 kB 7.44 kB — ▰▰▰▰▰▰▰▱▱▱ 74%
⚪ /shared (all exports) 175.5 kB 175.5 kB — ▰▰▰▰▰▰▰▰▰▱ 85%
⚪ /queries (all exports) 2.14 kB 2.14 kB — ▰▰▰▰▰▰▰▱▱▱ 71%
⚪ /skeleton (WidgetSkeleton) 44.0 kB 44.0 kB — ▰▰▰▰▰▰▰▰▱▱ 83%

@lifi/wallet-management

Import Base PR Change Budget used
⚪ all exports (without translations) 72.4 kB 72.4 kB — ▰▰▰▰▰▰▰▰▱▱ 82%

@lifi/widget-light

Import Base PR Change Budget used
⚪ LiFiWidgetLight 1.21 kB 1.21 kB — ▰▰▰▰▰▰▱▱▱▱ 60%

Providers

Import Base PR Change Budget used
⚪ ethereum 101.2 kB 101.4 kB 🔺 +0.27 kB (+0.3%) ▰▰▰▰▰▰▰▰▰▱ 87%
⚪ solana 31.9 kB 31.9 kB — ▰▰▰▰▰▰▰▰▱▱ 82%
⚪ bitcoin 89.1 kB 89.1 kB — ▰▰▰▰▰▰▰▰▱▱ 83%
⚪ sui 99.2 kB 99.2 kB — ▰▰▰▰▰▰▰▰▰▱ 89%
⚪ tron (incl. WalletConnect loaded on demand) 721.3 kB 721.3 kB — ▰▰▰▰▰▰▰▰▱▱ 82%
⚪ stellar 180.3 kB 180.3 kB — ▰▰▰▰▰▰▰▰▱▱ 85%
⚪ zcash 6.59 kB 6.59 kB — ▰▰▰▰▰▰▰▰▱▱ 82%

Full integration

Import Base PR Change Budget used
⚪ LiFiWidget + all providers + their peers (incl. on-demand code) 2003.3 kB 2003.3 kB 🔻 −0.04 kB (−0.0%) ▰▰▰▰▰▰▰▰▰▱ 87%
How to read this
  • Sizes are minified + brotli, measured with size-limit the way an app bundles the import: tree-shaken, with all dependencies included except the ones in the check's ignore list (packages the app already has, such as peers).
  • Code behind a dynamic import() is counted too, unless the check's name says otherwise.
  • 🟠 / 🟢 mark a change of at least 512 B and at least 1% or 5.00 kB. 🔴 means the check is over its budget.
  • Budgets live in .size-limit.json. The base commit is measured with the same checks as the PR.

ec4ba2e merged into 43221bb · workflow run

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

✅ E2E Dev Smoke — passing

Check Result
Dev server start (pnpm dev) ✅ started
Smoke tests ✅ passed

4 passed · 0 failed · 0 skipped · 19s

View run

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

E2E Examples — all passed

All examples passed in the latest run.

@github-actions

github-actions Bot commented Nov 12, 2026 •

Copy link
Copy Markdown
Contributor

E2E Playground results

passed  164 passed

Details

stats  164 tests across 11 suites
duration  2 minutes, 27 seconds
commit  ec4ba2e

📥 Download full HTML report (open the run → Artifacts → playwright-report)

…mitter wrapper (JUMEMB-192)

- Compare the connectors array item by item, so callers that pass a
  new array on each render (for example `[]`) still skip a sync.
- Store the last sync only after the setup, so a failed setup is tried
  again.
- Key the emitter wrapper per wagmi config, so two configs that share a
  connector function do not get each other's events.
- Build the chains key after the mainnet contracts merge and from the
  whole rpcUrls, blockExplorers and contracts, plus testnet.
… live chain list (JUMEMB-192)

Replace the chains key guard and the emitter forwarding wrapper with a
simpler model. Each connector function gets one connector object per
wagmi config, so connector SDKs that bind their event handlers once keep
reaching wagmi. Connectors read the live wagmi chain list through the
setup parameter, so a new chain needs no new setup. A chains refetch
only updates the wagmi chains; a change of the connector functions sets
up only the new ones and runs reconnect.
…allets too (JUMEMB-192)

wagmi sets up the connectors of injected wallets that it finds through
EIP-6963 from provider details. They also got a copy of the chain list,
so they could not switch to a chain that the API adds after the first
sync.
…connects (JUMEMB-192)

An independent review found three cases where main's reconnect on each
chains refetch hid a gap that the previous commit exposed:

- wagmi Hydrate clears the connections on each render when ssr is
  false, storage is set and reconnectOnMount is false. Reconnect when
  the current connection is missing.
- wagmi drops a reconnect while another one runs, so a connector added
  during the first reconnect (Jumper mini-app) was never connected.
  Queue the reconnect calls of the sync.
- An EIP-6963 wallet that wagmi set up itself kept a copy of the chain
  list. Keep one connector with the live chain list for each EIP-6963
  uuid, and replace the objects that the sync did not make.

The setup parameter is now a copy with property descriptors and a live
chains getter instead of a Proxy, so a frozen parameter cannot throw.
…and free old ones (JUMEMB-192)

A second review round found two issues in the previous commit:

- wagmi reconnect adds its change and disconnect listeners again on each
  reconnect of the same connector object, so each heal added one more.
  After each queued reconnect, set the listeners of the connected
  connectors to exactly one.
- The connector cache held old connector functions strongly. Use a
  WeakMap for connector functions and a Map only for EIP-6963 uuids.

This branch has not been deployed

No deployments
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.

1 participant