feat(connect): the provider and the hooks run on the connection machine - #126
feat(connect): the provider and the hooks run on the connection machine#126fernandomg wants to merge 30 commits into
Conversation
Ten `useState` fields written from six places are gone. The provider creates the actor, publishes it, and selects nothing: a provider that pre-selected the whole session re-rendered every consumer on every tick of it. The context value is the config, the actor as `ConnectionSubscription` and three identity-stable actions. Machine input is read once, at actor creation, so a changed `config` prop reaches the hooks and not the lifecycle.
`connect()` and `disconnect()` are a send plus a wait on a tag, so the promise over a transition lives outside the machine and no caller names a state. `connect()` resolves once the party lands, rejects with the wallet's own error when the account read fails, and resolves partyless when the wallet connects locked. A connect asked for mid-disconnect supersedes it instead of being dropped.
One `useSelector` per hook, so one slice changing no longer re-renders every consumer. - `useWalletCall` is the one skeleton the three SDK-calling hooks share: it refuses with no session, refuses while the wallet is locked, and `useLedger().isReady` reports both - `useTxFeed` keeps `lastTx` in React, the one piece of state the machine does not own - `useConnect().reset()` forgets the last connect error, and a disconnect forgets it too
`FakeSessionProvider` resolves a real `connectionMachine` state, so a component test selects exactly as it does in the app, and its `sdk` prop answers only the calls a hook makes. The rest is the setup each suite was repeating: the discovery keys a restore reads, a session render, a started connection, a picker that rejects.
Each bridge against a machine with stubbed actors: the state that answers it, the one that supersedes it, and the unmount that stops the actor without answering.
Connect, restore, wallet pushes and the lock guards each get their own file, driving the real facade through `createFakeWallet` rather than a stubbed sdk.
All of it rests on `dapp-sdk` internals no test can pin, so the chapter is the why: the `beforeunload` the picker loses, the message the drain posts, and where it breaks.
…N-RPC objects
- the window transport rejects with the JSON-RPC error object itself (core-rpc-transport 1.10.0),
so `connectError`, `useSignMessage().error` and `useExecute().error` carried a plain
`{ code, message }` under an `Error` type, and `connect()` rejected with it
- `toError` wraps anything that is not an `Error`, keeping the wallet's object as `cause`;
`toConnectError` and `useWalletCall` go through it
- the machine keeps what the wallet sent in `lastConnectError`; the classification is the hooks'
…ancels and config - `CantonConnectProviderProps`: `config` is read once at actor creation; a later `walletPicker` or `additionalAdapters` never re-runs discovery, since `useActorRef` takes its options once - `UseConnectResult`: `connectError` records a failure, and a picker the guard saw closed leaves it `undefined`; only a dismissal the SDK itself rejected is recorded as a cancel - `UseWalletStatusResult.isLocked`: the party is dropped while locked, as the machine does; only `status` stays put - `CantonConnectContextValue.disconnect`: settles on an unanswered wallet too, after the machine's 10 s deadline
…s the provider does - `FakeSessionProvider` published `resetConnectError: () => undefined`, so a consumer test that dismissed an error through it watched the error stay - it now sends `connectError.reset` to the actor it rehydrated, the event the provider sends
- the pessimistic disconnect removed disconnect.superseded, so the bridge no longer waits on it; a disconnect always lands in disconnected now - rewrite the interrupt test: a connect during a disconnect is ignored, the connect actor never runs, and the call rejects as cancelled once disconnected
Cut to what a reader cannot get from the tool's own docs: the actor-logic walkthrough, both sequence diagrams, the wagmi essays and a duplicated provider section are gone. The `dapp-sdk` mechanics stay in full. That SDK is under-documented and we depend on it, so its quirks are the one thing a reader genuinely cannot look up.
Only beside an `architecture.md` that indexes it, when a section outgrows the seam it describes. `canton-connect/` carries two today.
- the disconnect wait ends inside the machine after 10 s; the connect wait stays with #105
- b2db792 marked the bridges and the hook tags as not yet wired, true on feat/76-machine alone - at this tip the provider wires both, so the chapter states the wired behavior - the retiring failure edge and the wallet.statusChanged paragraph from that commit stay
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
# Conflicts: # CLAUDE.md # canton-connect/CLAUDE.md # canton-connect/architecture.md
- session dropped connect, so a wallet-side disconnect (same push as a lock) left no way back - connecting/retiring/restoring split into new/changing, and the changing pair reports connecting - a cancelled wallet change resumes the kept session without passing disconnected or idle
There was a problem hiding this comment.
Pull request overview
This is the third and final PR of a progressive series that reworks canton-connect's connection lifecycle. It replaces CantonConnectProvider's mirroring of SDK state across ~10 useState fields with a single XState connection machine that owns the lifecycle. The provider now holds no session React state — it publishes config, the connection actor, and the three actions (connect, disconnect, resetConnectError) — and each hook subscribes to its own machine slice via @xstate/react's useSelector, so a change in one slice re-renders only the hooks that read it. This closes the epic (#76, #85, #86, #57, #58) by making invalid state combinations unrepresentable and folding the previously patched races into one owner.
Changes:
CantonConnectProviderruns the connection machine and holds no session state; hooks (useConnect,useParty,useWalletStatus,useExecute,useSignMessage,useLedger) each select their own machine slice.- Adds locked-wallet guards:
useSignMessage/useExecute/useLedgerreject while locked (wallet is locked - unlock it in the wallet) anduseLedger().isReadyis false; init failures surface viaconnectErrorwhile silent restore failures are logged (not surfaced). - Adds
@xstate/reactdependency, new public types (CantonConnectContextValue,TxStatusSnapshot), extensive machine/provider tests and new testing helpers, plus documentation updates.
Reviewed changes
Copilot reviewed 47 out of 48 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
pnpm-lock.yaml |
Adds @xstate/react@^6.1.0 and its transitive use-isomorphic-layout-effect. |
CLAUDE.md |
Updates the canton-connect doc-distribution row to reflect the machine-owned lifecycle. |
canton-connect/src/types.ts |
Adds TxStatusSnapshot and the public CantonConnectContextValue; refines ConnectionSubscription doc wording. |
canton-connect/src/testing/walletLock.ts |
New helper pushing lock/unlock statusChanged events for wallet-a. |
canton-connect/src/testing/walletA.ts |
New shared fake-wallet factory for provider tests. |
canton-connect/src/testing/useSession.ts |
New test hook aggregating every published slice (plus sdk) into one object. |
canton-connect/src/testing/throwingPicker.ts |
New picker that always throws, for never-succeed connect tests. |
canton-connect/src/testing/startConnection.ts |
New helper booting a machine variant on the fake input. |
canton-connect/src/testing/renderSession.tsx |
New helper rendering a hook inside a real provider with an auto-picker default. |
canton-connect/src/testing/fakeSession.tsx |
Reworks the fake session provider to drive a real actor state via the machine. |
Files not reviewed (1)
- pnpm-lock.yaml: Generated file
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
- the merge committed a hybrid: an importer keyed on xstate@5.32.5, entries only for 5.32.6 - CI's frozen install rejects it; local installs passed on the up-to-date fast path - main's lockfile plus the one added dependency keeps the PR merge textually trivial
- contract facts move to the leading blocks; the stale connects-locked claim goes
- dead {@link} targets get type-only imports
Summary
Closes #76, Closes #85, closes #86, closes #57, closes #58
Third of three progressive PRs; merging this one closes the epic.
This swaps
CantonConnectProvider, stops mirroring SDK state into multipleuseStatefields and runs the machine instead.Changes
CantonConnectProviderruns the connection machine and holds no React state for the session. It publishes:config;connection, the actor;connect,disconnectandresetConnectError, the three actions.connection, so a change in one slice re-renders only the hooks reading ituseSignMessage,useExecuteanduseLedgeralso reject while the wallet is locked (wallet is locked - unlock it in the wallet), anduseLedger().isReadyis false thenconnect()over a standing session runs a wallet change: the only recovery after a wallet-side disconnect, and cancelling it keeps the sessionDeviations
#57expects a failedinitand a failed restore to surface an error. They are treated differently here:initconnectErroris set; the cause logs atconsole.errorconsole.debugAcceptance criteria
xstate@5machine handles the connection lifecycledapp/frontendbehaves as it did before this changeTest plan
Automated tests
pnpm -C canton-connect testManual verification
Run the frontend app and connect an extension wallet.
Breaking changes
Nothing is published yet, and
dapp/frontendandcanton-dappboosteruse only the hooks, so nothing in the repo needed a change.useCantonConnectContext().partyuseParty().partyuseCantonConnectContext().statususeParty().statususeCantonConnectContext().isLockeduseWalletStatus().isLockeduseCantonConnectContext().connectErroruseConnect().connectErroruseCantonConnectContext().isConnectinguseConnect().isConnectinguseCantonConnectContext().lastTxuseExecute().lastTxuseCantonConnectContext().sdkuseCantonConnectContext().connection.getSnapshot().context.sdkconnectErrorconnectErrorstaysundefined{ code, message }objectError, inconnectError,useSignMessage().error,useExecute().errorandconnect()'s rejectionerror.codeerror.cause.codeChecklist
Screenshots
None.