Skip to content

Commit 3620a98

Browse files
committed
fix: clean
1 parent 5b9c3c7 commit 3620a98

8 files changed

Lines changed: 357 additions & 283 deletions

File tree

packages/client-state-controller/CHANGELOG.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,14 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Changed
11+
12+
- **BREAKING:** Selectors are now exported under `clientStateControllerSelectors` instead of as standalone functions. Use `clientStateControllerSelectors.selectIsClientOpen(state)` instead of `selectIsClientOpen(state)`.
13+
1014
### Added
1115

1216
- Initial release of `@metamask/client-state-controller` (renamed from `@metamask/application-state-controller`)
1317
- `ClientStateController` for managing client (UI) open/closed state
1418
- `ClientStateController:setClientOpen` messenger action for platform code to call
1519
- `ClientStateController:stateChange` event for controllers to subscribe to lifecycle changes
1620
- `isClientOpen` state property (not persisted - always starts as `false`)
17-
- `selectIsClientOpen` selector for derived state access
21+
- `clientStateControllerSelectors.selectIsClientOpen` selector for derived state access
1822
- Full TypeScript support with exported types
1923

2024
[Unreleased]: https://github.com/MetaMask/core/

packages/client-state-controller/README.md

Lines changed: 70 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,52 +1,29 @@
11
# `@metamask/client-state-controller`
22

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

55
## Overview
66

77
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.
88

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

11-
Previously, lifecycle management was scattered across platform code:
11+
## Important: Usage guidelines and warnings
1212

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:
2614

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
2818

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:**
3620

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).
5027

5128
## Installation
5229

@@ -105,78 +82,100 @@ class MetamaskController {
10582
import { AppState } from 'react-native';
10683

10784
// In Engine initialization
108-
AppState.addEventListener('change', (nextAppState) => {
109-
if (nextAppState !== 'active' && nextAppState !== 'background') {
110-
return;
111-
}
85+
AppState.addEventListener('change', (state) => {
11286
controllerMessenger.call(
11387
'ClientStateController:setClientOpen',
114-
nextAppState === 'active',
88+
state === 'active',
11589
);
11690
});
11791
```
11892

11993
### Consumer Controller
12094

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+
12199
```typescript
100+
import { clientStateControllerSelectors } from '@metamask/client-state-controller';
101+
122102
class TokenBalancesController extends BaseController {
123103
constructor({ messenger }) {
124104
super({ messenger, ... });
125105

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.
127108
this.messenger.subscribe(
128109
'ClientStateController:stateChange',
129-
(newState) => {
130-
if (newState.isClientOpen) {
131-
this.startPolling();
110+
(isClientOpen) => {
111+
if (isClientOpen) {
112+
this.resumePolling();
132113
} else {
133-
this.stopPolling();
114+
this.pausePolling();
134115
}
135116
},
117+
(state) => clientStateControllerSelectors.selectIsClientOpen(state),
136118
);
137119
}
138120

139-
startPolling() {
140-
// Start polling when client opens
121+
resumePolling() {
122+
// Start polling if previously paused, otherwise do nothing
141123
}
142124

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
145128
}
146129
}
147130
```
148131

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

151143
```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;
154149

155150
constructor({ messenger }) {
156151
super({ messenger, ... });
157152

158153
messenger.subscribe(
159154
'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();
166158
},
159+
(state) => clientStateControllerSelectors.selectIsClientOpen(state),
167160
);
168-
}
169161

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+
});
174171
}
175172

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();
180179
}
181180
}
182181
}
@@ -208,10 +207,10 @@ Note: State is not persisted. It always starts as `false`.
208207
### Selectors
209208

210209
```typescript
211-
import { selectIsClientOpen } from '@metamask/client-state-controller';
210+
import { clientStateControllerSelectors } from '@metamask/client-state-controller';
212211

213212
const state = messenger.call('ClientStateController:getState');
214-
const isOpen = selectIsClientOpen(state);
213+
const isOpen = clientStateControllerSelectors.selectIsClientOpen(state);
215214
```
216215

217216
## Contributing
Lines changed: 72 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,72 @@
1-
{"name":"@metamask/client-state-controller","version":"0.0.0","description":"Manages client lifecycle state (client open/closed) for cross-platform MetaMask applications","keywords":["MetaMask","Ethereum"],"homepage":"https://github.com/MetaMask/core/tree/main/packages/client-state-controller#readme","bugs":{"url":"https://github.com/MetaMask/core/issues"},"repository":{"type":"git","url":"https://github.com/MetaMask/core.git"},"license":"MIT","sideEffects":false,"exports":{".":{"import":{"types":"./dist/index.d.mts","default":"./dist/index.mjs"},"require":{"types":"./dist/index.d.cts","default":"./dist/index.cjs"}},"./package.json":"./package.json"},"main":"./dist/index.cjs","types":"./dist/index.d.cts","files":["dist/"],"scripts":{"build":"ts-bridge --project tsconfig.build.json --verbose --clean --no-references","build:all":"ts-bridge --project tsconfig.build.json --verbose --clean","build:docs":"typedoc","changelog:update":"../../scripts/update-changelog.sh @metamask/client-state-controller","changelog:validate":"../../scripts/validate-changelog.sh @metamask/client-state-controller","publish:preview":"yarn npm publish --tag preview","since-latest-release":"../../scripts/since-latest-release.sh","test":"NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter","test:clean":"NODE_OPTIONS=--experimental-vm-modules jest --clearCache","test:verbose":"NODE_OPTIONS=--experimental-vm-modules jest --verbose","test:watch":"NODE_OPTIONS=--experimental-vm-modules jest --watch"},"dependencies":{"@metamask/base-controller":"^9.0.0","@metamask/messenger":"^0.3.0"},"devDependencies":{"@metamask/auto-changelog":"^3.4.4","@ts-bridge/cli":"^0.6.4","@types/jest":"^27.5.2","deepmerge":"^4.2.2","jest":"^27.5.1","ts-jest":"^27.1.5","typedoc":"^0.24.8","typedoc-plugin-missing-exports":"^2.0.0","typescript":"~5.3.3"},"engines":{"node":"^18.18 || >=20"},"publishConfig":{"access":"public","registry":"https://registry.npmjs.org/"}}
1+
{
2+
"name": "@metamask/client-state-controller",
3+
"version": "0.0.0",
4+
"description": "Tracks and manages the lifecycle state of MetaMask as a client.",
5+
"keywords": [
6+
"MetaMask",
7+
"Ethereum"
8+
],
9+
"homepage": "https://github.com/MetaMask/core/tree/main/packages/client-state-controller#readme",
10+
"bugs": {
11+
"url": "https://github.com/MetaMask/core/issues"
12+
},
13+
"repository": {
14+
"type": "git",
15+
"url": "https://github.com/MetaMask/core.git"
16+
},
17+
"license": "MIT",
18+
"sideEffects": false,
19+
"exports": {
20+
".": {
21+
"import": {
22+
"types": "./dist/index.d.mts",
23+
"default": "./dist/index.mjs"
24+
},
25+
"require": {
26+
"types": "./dist/index.d.cts",
27+
"default": "./dist/index.cjs"
28+
}
29+
},
30+
"./package.json": "./package.json"
31+
},
32+
"main": "./dist/index.cjs",
33+
"types": "./dist/index.d.cts",
34+
"files": [
35+
"dist/"
36+
],
37+
"scripts": {
38+
"build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references",
39+
"build:all": "ts-bridge --project tsconfig.build.json --verbose --clean",
40+
"build:docs": "typedoc",
41+
"changelog:update": "../../scripts/update-changelog.sh @metamask/client-state-controller",
42+
"changelog:validate": "../../scripts/validate-changelog.sh @metamask/client-state-controller",
43+
"publish:preview": "yarn npm publish --tag preview",
44+
"since-latest-release": "../../scripts/since-latest-release.sh",
45+
"test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter",
46+
"test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache",
47+
"test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose",
48+
"test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch"
49+
},
50+
"dependencies": {
51+
"@metamask/base-controller": "^9.0.0",
52+
"@metamask/messenger": "^0.3.0"
53+
},
54+
"devDependencies": {
55+
"@metamask/auto-changelog": "^3.4.4",
56+
"@ts-bridge/cli": "^0.6.4",
57+
"@types/jest": "^27.5.2",
58+
"deepmerge": "^4.2.2",
59+
"jest": "^27.5.1",
60+
"ts-jest": "^27.1.5",
61+
"typedoc": "^0.24.8",
62+
"typedoc-plugin-missing-exports": "^2.0.0",
63+
"typescript": "~5.3.3"
64+
},
65+
"engines": {
66+
"node": "^18.18 || >=20"
67+
},
68+
"publishConfig": {
69+
"access": "public",
70+
"registry": "https://registry.npmjs.org/"
71+
}
72+
}

0 commit comments

Comments
 (0)