|
1 | 1 | # `@metamask/client-state-controller` |
2 | 2 |
|
3 | | -Manages client lifecycle state (client open/closed) for cross-platform MetaMask applications. |
| 3 | +Tracks and manages the lifecycle state of MetaMask as a client. |
4 | 4 |
|
5 | 5 | ## Overview |
6 | 6 |
|
7 | 7 | The `ClientStateController` provides a centralized way for controllers to respond to application lifecycle changes. Platform code calls `ClientStateController:setClientOpen` via messenger, and other controllers subscribe to `stateChange` events. |
8 | 8 |
|
9 | | -### The Problem It Solves |
| 9 | +**Use this state and events together with other lifecycle signals** (e.g. `KeyringController:unlock` / `KeyringController:lock`). Whether the client is "open" is only one condition; you often also need the keyring unlocked (user has completed onboarding / is logged in) before starting network requests or sensitive work. See [Using with other lifecycle state](#using-with-other-lifecycle-state-eg-keyring-unlocklock) below. |
10 | 10 |
|
11 | | -Previously, lifecycle management was scattered across platform code: |
| 11 | +## Important: Usage guidelines and warnings |
12 | 12 |
|
13 | | -```typescript |
14 | | -// In MetamaskController (extension) |
15 | | -set isClientOpen(open) { |
16 | | - this._isClientOpen = open; |
17 | | - // Manually call each controller/service |
18 | | - this.controllerMessenger.call('SnapController:setClientActive', open); |
19 | | - if (open) { |
20 | | - this.controllerMessenger.call('BackendWebSocketService:connect'); |
21 | | - } else { |
22 | | - this.controllerMessenger.call('BackendWebSocketService:disconnect'); |
23 | | - } |
24 | | -} |
25 | | -``` |
| 13 | +**Do not subscribe to updates for all kinds of data as soon as the client opens.** When MetaMask opens, the current screen may not need every type of data. Starting subscriptions, polling, or network requests for everything when `isClientOpen` becomes true is not a good long-term strategy and can lead to: |
26 | 14 |
|
27 | | -### The Solution |
| 15 | +- Unnecessary network traffic and battery use |
| 16 | +- **Requests before onboarding is complete** — we have run into problems in the past with making network requests before users complete onboarding; the same issues can recur if consumers start all their updates as soon as the client is "open" |
| 17 | +- Poor performance and scalability as more features are added |
28 | 18 |
|
29 | | -With `ClientStateController`, controllers manage themselves: |
30 | | - |
31 | | -```typescript |
32 | | -// Platform code calls the controller via messenger |
33 | | -set isClientOpen(open) { |
34 | | - this.controllerMessenger.call('ClientStateController:setClientOpen', open); |
35 | | -} |
| 19 | +**Use this controller responsibly:** |
36 | 20 |
|
37 | | -// Controllers subscribe to stateChange and manage themselves |
38 | | -class MyController extends BaseController { |
39 | | - constructor({ messenger }) { |
40 | | - messenger.subscribe('ClientStateController:stateChange', (newState) => { |
41 | | - if (newState.isClientOpen) { |
42 | | - this.start(); |
43 | | - } else { |
44 | | - this.stop(); |
45 | | - } |
46 | | - }); |
47 | | - } |
48 | | -} |
49 | | -``` |
| 21 | +- Start only the subscriptions, polling, or requests that are **needed for the current screen or flow** |
| 22 | +- Do **not** start network-dependent or heavy behavior solely because `ClientStateController:stateChange` reported `isClientOpen: true` |
| 23 | +- Consider **deferring** non-critical updates until the user has completed onboarding or reached a screen that needs that data |
| 24 | +- Prefer starting and stopping per feature or per screen (e.g., when a component mounts that needs the data) rather than globally when the client opens |
| 25 | +- **Combine with Keyring unlock/lock:** Think about using `ClientStateController` state together with `KeyringController:unlock` and `KeyringController:lock` (or equivalent). Only start work when it is appropriate for both client visibility and wallet state (e.g. client open **and** keyring unlocked). |
| 26 | +- **Prefer pause/resume over stop/start for polling:** When reacting to client open/close, prefer pausing and resuming polling (so you can resume without full re-initialization) rather than stopping and starting from scratch. Use the selector when subscribing (see example below). |
50 | 27 |
|
51 | 28 | ## Installation |
52 | 29 |
|
@@ -105,78 +82,100 @@ class MetamaskController { |
105 | 82 | import { AppState } from 'react-native'; |
106 | 83 |
|
107 | 84 | // In Engine initialization |
108 | | -AppState.addEventListener('change', (nextAppState) => { |
109 | | - if (nextAppState !== 'active' && nextAppState !== 'background') { |
110 | | - return; |
111 | | - } |
| 85 | +AppState.addEventListener('change', (state) => { |
112 | 86 | controllerMessenger.call( |
113 | 87 | 'ClientStateController:setClientOpen', |
114 | | - nextAppState === 'active', |
| 88 | + state === 'active', |
115 | 89 | ); |
116 | 90 | }); |
117 | 91 | ``` |
118 | 92 |
|
119 | 93 | ### Consumer Controller |
120 | 94 |
|
| 95 | +Use `ClientStateController:stateChange` only for behavior that **must** run when the client is open or closed (e.g., pausing/resuming a single critical background task). Do not use it to start all possible updates; see [Usage guidelines and warnings](#important-usage-guidelines-and-warnings) above. |
| 96 | + |
| 97 | +**Use the selector** when subscribing so the handler receives a single derived value (e.g. `isClientOpen`), and **prefer pause/resume** over stop/start for polling so you can resume without full re-initialization. |
| 98 | + |
121 | 99 | ```typescript |
| 100 | +import { clientStateControllerSelectors } from '@metamask/client-state-controller'; |
| 101 | + |
122 | 102 | class TokenBalancesController extends BaseController { |
123 | 103 | constructor({ messenger }) { |
124 | 104 | super({ messenger, ... }); |
125 | 105 |
|
126 | | - // Subscribe to lifecycle state changes |
| 106 | + // Subscribe with a selector so the handler receives isClientOpen (boolean). |
| 107 | + // Prefer pause/resume so polling can be resumed without full re-initialization. |
127 | 108 | this.messenger.subscribe( |
128 | 109 | 'ClientStateController:stateChange', |
129 | | - (newState) => { |
130 | | - if (newState.isClientOpen) { |
131 | | - this.startPolling(); |
| 110 | + (isClientOpen) => { |
| 111 | + if (isClientOpen) { |
| 112 | + this.resumePolling(); |
132 | 113 | } else { |
133 | | - this.stopPolling(); |
| 114 | + this.pausePolling(); |
134 | 115 | } |
135 | 116 | }, |
| 117 | + (state) => clientStateControllerSelectors.selectIsClientOpen(state), |
136 | 118 | ); |
137 | 119 | } |
138 | 120 |
|
139 | | - startPolling() { |
140 | | - // Start polling when client opens |
| 121 | + resumePolling() { |
| 122 | + // Start polling if previously paused, otherwise do nothing |
141 | 123 | } |
142 | 124 |
|
143 | | - stopPolling() { |
144 | | - // Stop polling when client closes |
| 125 | + pausePolling() { |
| 126 | + // Mark that polling is paused so resumePolling can restart it later, |
| 127 | + // and ensure that polling is stopped |
145 | 128 | } |
146 | 129 | } |
147 | 130 | ``` |
148 | 131 |
|
149 | | -### WebSocket Controller Example |
| 132 | +Note: `stateChange` emits `[state, patches]`; the selector receives the full payload and returns the value passed to the handler (here, `isClientOpen`). |
| 133 | + |
| 134 | +### Using with other lifecycle state (e.g. Keyring unlock/lock) |
| 135 | + |
| 136 | +Client open/close alone is usually not enough to decide when to start or stop work. Combine `ClientStateController:stateChange` with other lifecycle events and state, such as: |
| 137 | + |
| 138 | +- **KeyringController:unlock** / **KeyringController:lock** — whether the wallet is unlocked (user has completed onboarding / is logged in) |
| 139 | +- Any other controller that expresses "ready for background work" or "user session active" |
| 140 | + |
| 141 | +Only start subscriptions, polling, or network requests when **both** the client is open and the keyring (or equivalent) is unlocked. Stop or pause when the client closes **or** the keyring locks. |
150 | 142 |
|
151 | 143 | ```typescript |
152 | | -class WebSocketController extends BaseController { |
153 | | - #socket: WebSocket | null = null; |
| 144 | +import { clientStateControllerSelectors } from '@metamask/client-state-controller'; |
| 145 | + |
| 146 | +class SomeDataController extends BaseController { |
| 147 | + #clientOpen = false; |
| 148 | + #keyringUnlocked = false; |
154 | 149 |
|
155 | 150 | constructor({ messenger }) { |
156 | 151 | super({ messenger, ... }); |
157 | 152 |
|
158 | 153 | messenger.subscribe( |
159 | 154 | 'ClientStateController:stateChange', |
160 | | - (newState) => { |
161 | | - if (newState.isClientOpen) { |
162 | | - this.connect(); |
163 | | - } else { |
164 | | - this.disconnect(); |
165 | | - } |
| 155 | + (isClientOpen) => { |
| 156 | + this.#clientOpen = isClientOpen; |
| 157 | + this.updateActive(); |
166 | 158 | }, |
| 159 | + (state) => clientStateControllerSelectors.selectIsClientOpen(state), |
167 | 160 | ); |
168 | | - } |
169 | 161 |
|
170 | | - connect() { |
171 | | - if (!this.#socket) { |
172 | | - this.#socket = new WebSocket('wss://example.com'); |
173 | | - } |
| 162 | + messenger.subscribe('KeyringController:unlock', () => { |
| 163 | + this.#keyringUnlocked = true; |
| 164 | + this.updateActive(); |
| 165 | + }); |
| 166 | + |
| 167 | + messenger.subscribe('KeyringController:lock', () => { |
| 168 | + this.#keyringUnlocked = false; |
| 169 | + this.updateActive(); |
| 170 | + }); |
174 | 171 | } |
175 | 172 |
|
176 | | - disconnect() { |
177 | | - if (this.#socket) { |
178 | | - this.#socket.close(); |
179 | | - this.#socket = null; |
| 173 | + updateActive() { |
| 174 | + const shouldRun = this.#clientOpen && this.#keyringUnlocked; |
| 175 | + if (shouldRun) { |
| 176 | + this.resume(); |
| 177 | + } else { |
| 178 | + this.pause(); |
180 | 179 | } |
181 | 180 | } |
182 | 181 | } |
@@ -208,10 +207,10 @@ Note: State is not persisted. It always starts as `false`. |
208 | 207 | ### Selectors |
209 | 208 |
|
210 | 209 | ```typescript |
211 | | -import { selectIsClientOpen } from '@metamask/client-state-controller'; |
| 210 | +import { clientStateControllerSelectors } from '@metamask/client-state-controller'; |
212 | 211 |
|
213 | 212 | const state = messenger.call('ClientStateController:getState'); |
214 | | -const isOpen = selectIsClientOpen(state); |
| 213 | +const isOpen = clientStateControllerSelectors.selectIsClientOpen(state); |
215 | 214 | ``` |
216 | 215 |
|
217 | 216 | ## Contributing |
|
0 commit comments