From fa336335a237248038aad4c98ea863f6a0498a23 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Mon, 2 Feb 2026 14:27:30 +0100 Subject: [PATCH 1/7] feat(ApplicationState): init controller --- .github/CODEOWNERS | 1 + .../application-state-controller/CHANGELOG.md | 20 ++ packages/application-state-controller/LICENSE | 21 ++ .../application-state-controller/README.md | 219 +++++++++++++++ .../jest.config.js | 24 ++ .../application-state-controller/package.json | 72 +++++ .../src/ApplicationStateController.test.ts | 259 ++++++++++++++++++ .../src/ApplicationStateController.ts | 235 ++++++++++++++++ .../application-state-controller/src/index.ts | 17 ++ .../tsconfig.build.json | 17 ++ .../tsconfig.json | 7 + .../application-state-controller/typedoc.json | 8 + teams.json | 1 + yarn.lock | 18 ++ 14 files changed, 919 insertions(+) create mode 100644 packages/application-state-controller/CHANGELOG.md create mode 100644 packages/application-state-controller/LICENSE create mode 100644 packages/application-state-controller/README.md create mode 100644 packages/application-state-controller/jest.config.js create mode 100644 packages/application-state-controller/package.json create mode 100644 packages/application-state-controller/src/ApplicationStateController.test.ts create mode 100644 packages/application-state-controller/src/ApplicationStateController.ts create mode 100644 packages/application-state-controller/src/index.ts create mode 100644 packages/application-state-controller/tsconfig.build.json create mode 100644 packages/application-state-controller/tsconfig.json create mode 100644 packages/application-state-controller/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 18c0c5a3f8e..ed6c489d909 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,6 +71,7 @@ /packages/eip-5792-middleware @MetaMask/wallet-integrations ## Core Platform Team +/packages/application-state-controller @MetaMask/core-platform /packages/base-controller @MetaMask/core-platform /packages/build-utils @MetaMask/core-platform /packages/composable-controller @MetaMask/core-platform diff --git a/packages/application-state-controller/CHANGELOG.md b/packages/application-state-controller/CHANGELOG.md new file mode 100644 index 00000000000..df71463ce90 --- /dev/null +++ b/packages/application-state-controller/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `@metamask/application-state-controller` ([#7808](https://github.com/MetaMask/core/pull/7808)) + - `ApplicationStateController` for managing client (UI) open/closed state + - `ApplicationStateController:setClientState` messenger action for platform code to call + - `ApplicationStateController:stateChange` event for controllers to subscribe to lifecycle changes + - `isClientOpen` state property (not persisted - always starts as `false`) + - `selectIsClientOpen` selector for derived state access + - Full TypeScript support with exported types + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/application-state-controller/LICENSE b/packages/application-state-controller/LICENSE new file mode 100644 index 00000000000..37484ffd950 --- /dev/null +++ b/packages/application-state-controller/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/application-state-controller/README.md b/packages/application-state-controller/README.md new file mode 100644 index 00000000000..502bfd48d08 --- /dev/null +++ b/packages/application-state-controller/README.md @@ -0,0 +1,219 @@ +# `@metamask/application-state-controller` + +Manages application lifecycle state (client open/closed) for cross-platform MetaMask applications. + +## Overview + +The `ApplicationStateController` provides a centralized way for controllers to respond to application lifecycle changes. Platform code calls `ApplicationStateController:setClientState` via messenger, and other controllers subscribe to `stateChange` events. + +### The Problem It Solves + +Previously, lifecycle management was scattered across platform code: + +```typescript +// In MetamaskController (extension) +set isClientOpen(open) { + this._isClientOpen = open; + // Manually call each controller/service + this.controllerMessenger.call('SnapController:setClientActive', open); + if (open) { + this.controllerMessenger.call('BackendWebSocketService:connect'); + } else { + this.controllerMessenger.call('BackendWebSocketService:disconnect'); + } +} +``` + +### The Solution + +With `ApplicationStateController`, controllers manage themselves: + +```typescript +// Platform code calls the controller via messenger +set isClientOpen(open) { + this.controllerMessenger.call('ApplicationStateController:setClientState', open); +} + +// Controllers subscribe to stateChange and manage themselves +class MyController extends BaseController { + constructor({ messenger }) { + messenger.subscribe('ApplicationStateController:stateChange', (newState) => { + if (newState.isClientOpen) { + this.start(); + } else { + this.stop(); + } + }); + } +} +``` + +## Installation + +`yarn add @metamask/application-state-controller` + +or + +`npm install @metamask/application-state-controller` + +## Usage + +### Basic Setup + +```typescript +import { Messenger } from '@metamask/messenger'; +import { + ApplicationStateController, + ApplicationStateControllerActions, + ApplicationStateControllerEvents, +} from '@metamask/application-state-controller'; + +const rootMessenger = new Messenger< + 'Root', + ApplicationStateControllerActions, + ApplicationStateControllerEvents +>({ namespace: 'Root' }); + +const controllerMessenger = new Messenger({ + namespace: 'ApplicationStateController', + parent: rootMessenger, +}); + +const applicationStateController = new ApplicationStateController({ + messenger: controllerMessenger, +}); +``` + +### Platform Integration (Extension) + +```typescript +// In MetamaskController +class MetamaskController { + // Platform calls this when UI opens/closes + set isClientOpen(open) { + this.controllerMessenger.call( + 'ApplicationStateController:setClientState', + open, + ); + } +} +``` + +### Platform Integration (React Native) + +```typescript +import { AppState } from 'react-native'; + +// In Engine initialization +AppState.addEventListener('change', (nextAppState) => { + if (nextAppState !== 'active' && nextAppState !== 'background') { + return; + } + controllerMessenger.call( + 'ApplicationStateController:setClientState', + nextAppState === 'active', + ); +}); +``` + +### Consumer Controller + +```typescript +class TokenBalancesController extends BaseController { + constructor({ messenger }) { + super({ messenger, ... }); + + // Subscribe to lifecycle state changes + this.messenger.subscribe( + 'ApplicationStateController:stateChange', + (newState) => { + if (newState.isClientOpen) { + this.startPolling(); + } else { + this.stopPolling(); + } + }, + ); + } + + startPolling() { + // Start polling when client opens + } + + stopPolling() { + // Stop polling when client closes + } +} +``` + +### WebSocket Controller Example + +```typescript +class WebSocketController extends BaseController { + #socket: WebSocket | null = null; + + constructor({ messenger }) { + super({ messenger, ... }); + + messenger.subscribe( + 'ApplicationStateController:stateChange', + (newState) => { + if (newState.isClientOpen) { + this.connect(); + } else { + this.disconnect(); + } + }, + ); + } + + connect() { + if (!this.#socket) { + this.#socket = new WebSocket('wss://example.com'); + } + } + + disconnect() { + if (this.#socket) { + this.#socket.close(); + this.#socket = null; + } + } +} +``` + +## API Reference + +### State + +| Property | Type | Description | +| -------------- | --------- | ------------------------------------------ | +| `isClientOpen` | `boolean` | Whether the client (UI) is currently open. | + +Note: State is not persisted. It always starts as `false`. + +### Actions + +| Action | Parameters | Description | +| ------------------------------------------- | --------------- | -------------------------------- | +| `ApplicationStateController:getState` | none | Returns current state. | +| `ApplicationStateController:setClientState` | `open: boolean` | Sets whether the client is open. | + +### Events + +| Event | Payload | Description | +| ---------------------------------------- | ------------------ | ---------------------------- | +| `ApplicationStateController:stateChange` | `[state, patches]` | Standard state change event. | + +### Selectors + +```typescript +import { selectIsClientOpen } from '@metamask/application-state-controller'; + +const state = messenger.call('ApplicationStateController:getState'); +const isOpen = selectIsClientOpen(state); +``` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/application-state-controller/jest.config.js b/packages/application-state-controller/jest.config.js new file mode 100644 index 00000000000..9efbc1e7d1f --- /dev/null +++ b/packages/application-state-controller/jest.config.js @@ -0,0 +1,24 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + displayName, + coveragePathIgnorePatterns: [], + coverageThreshold: { + global: { + branches: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/application-state-controller/package.json b/packages/application-state-controller/package.json new file mode 100644 index 00000000000..1d668b0a1f5 --- /dev/null +++ b/packages/application-state-controller/package.json @@ -0,0 +1,72 @@ +{ + "name": "@metamask/application-state-controller", + "version": "0.1.0", + "description": "Manages application lifecycle state (client open/closed) for cross-platform MetaMask applications", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/application-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/application-state-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/application-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/" + } +} diff --git a/packages/application-state-controller/src/ApplicationStateController.test.ts b/packages/application-state-controller/src/ApplicationStateController.test.ts new file mode 100644 index 00000000000..fe79a4d3d89 --- /dev/null +++ b/packages/application-state-controller/src/ApplicationStateController.test.ts @@ -0,0 +1,259 @@ +import { Messenger } from '@metamask/messenger'; + +import type { + ApplicationStateControllerActions, + ApplicationStateControllerEvents, + ApplicationStateControllerMessenger, + ApplicationStateControllerState, +} from './ApplicationStateController'; +import { + ApplicationStateController, + controllerName, + getDefaultApplicationStateControllerState, + selectIsClientOpen, +} from './ApplicationStateController'; + +describe('ApplicationStateController', () => { + /** + * Creates a messenger for the ApplicationStateController. + * + * @returns A messenger for the controller. + */ + function createMessenger(): ApplicationStateControllerMessenger { + const rootMessenger = new Messenger< + 'Root', + ApplicationStateControllerActions, + ApplicationStateControllerEvents + >({ namespace: 'Root' }); + + return new Messenger< + typeof controllerName, + ApplicationStateControllerActions, + ApplicationStateControllerEvents, + typeof rootMessenger + >({ + namespace: controllerName, + parent: rootMessenger, + }); + } + + /** + * Creates an ApplicationStateController. + * + * @param options - Options for creating the controller. + * @param options.state - Initial state to set on the controller. + * @returns The controller and messenger. + */ + function createController(options?: { + state?: Partial; + }): { + controller: ApplicationStateController; + messenger: ApplicationStateControllerMessenger; + } { + const messenger = createMessenger(); + const controller = new ApplicationStateController({ + messenger, + state: options?.state, + }); + return { controller, messenger }; + } + + describe('constructor', () => { + it('initializes with default state (client closed)', () => { + const { controller } = createController(); + + expect(controller.state.isClientOpen).toBe(false); + expect(controller.isClientOpen).toBe(false); + }); + + it('allows initializing with partial state', () => { + const { controller } = createController({ + state: { isClientOpen: true }, + }); + + expect(controller.state.isClientOpen).toBe(true); + expect(controller.isClientOpen).toBe(true); + }); + + it('merges partial state with defaults', () => { + const { controller } = createController({ + state: {}, + }); + + expect(controller.state.isClientOpen).toBe(false); + }); + }); + + describe('setClientState', () => { + it('updates state when client opens', () => { + const { controller } = createController(); + + controller.setClientState(true); + + expect(controller.state.isClientOpen).toBe(true); + expect(controller.isClientOpen).toBe(true); + }); + + it('updates state when client closes', () => { + const { controller } = createController(); + controller.setClientState(true); + + controller.setClientState(false); + + expect(controller.state.isClientOpen).toBe(false); + expect(controller.isClientOpen).toBe(false); + }); + + it('does not update state when setting the same value', () => { + const { controller, messenger } = createController(); + controller.setClientState(true); + const listener = jest.fn(); + messenger.subscribe(`${controllerName}:stateChange`, listener); + + controller.setClientState(true); + + expect(listener).not.toHaveBeenCalled(); + }); + + it('publishes stateChange event when client opens', () => { + const { controller, messenger } = createController(); + const listener = jest.fn(); + + messenger.subscribe(`${controllerName}:stateChange`, listener); + controller.setClientState(true); + + expect(listener).toHaveBeenCalledTimes(1); + const [newState] = listener.mock.calls[0]; + expect(newState.isClientOpen).toBe(true); + }); + + it('publishes stateChange event when client closes', () => { + const { controller, messenger } = createController(); + controller.setClientState(true); + const listener = jest.fn(); + + messenger.subscribe(`${controllerName}:stateChange`, listener); + controller.setClientState(false); + + expect(listener).toHaveBeenCalledTimes(1); + const [newState] = listener.mock.calls[0]; + expect(newState.isClientOpen).toBe(false); + }); + + it('does not publish stateChange when state does not change', () => { + const { controller, messenger } = createController(); + controller.setClientState(true); + const listener = jest.fn(); + + messenger.subscribe(`${controllerName}:stateChange`, listener); + controller.setClientState(true); + + expect(listener).not.toHaveBeenCalled(); + }); + }); + + describe('isClientOpen getter', () => { + it('returns true when client is open', () => { + const { controller } = createController(); + controller.setClientState(true); + + expect(controller.isClientOpen).toBe(true); + }); + + it('returns false when client is closed', () => { + const { controller } = createController(); + + expect(controller.isClientOpen).toBe(false); + }); + }); + + describe('messenger actions', () => { + it('allows getting state via messenger action', () => { + const { controller, messenger } = createController(); + controller.setClientState(true); + + const state = messenger.call(`${controllerName}:getState`); + + expect(state.isClientOpen).toBe(true); + }); + + it('allows setting client open via messenger action', () => { + const { controller, messenger } = createController(); + + messenger.call(`${controllerName}:setClientState`, true); + + expect(controller.state.isClientOpen).toBe(true); + }); + + it('allows setting client closed via messenger action', () => { + const { controller, messenger } = createController(); + controller.setClientState(true); + + messenger.call(`${controllerName}:setClientState`, false); + + expect(controller.state.isClientOpen).toBe(false); + }); + + it('publishes stateChange when setting via messenger action', () => { + const { messenger } = createController(); + const listener = jest.fn(); + + messenger.subscribe(`${controllerName}:stateChange`, listener); + messenger.call(`${controllerName}:setClientState`, true); + + expect(listener).toHaveBeenCalledTimes(1); + const [newState] = listener.mock.calls[0]; + expect(newState.isClientOpen).toBe(true); + }); + }); + + describe('getDefaultApplicationStateControllerState', () => { + it('returns default state with client closed', () => { + const defaultState = getDefaultApplicationStateControllerState(); + + expect(defaultState.isClientOpen).toBe(false); + }); + }); + + describe('selectors', () => { + describe('selectIsClientOpen', () => { + it('returns true when client is open', () => { + expect(selectIsClientOpen({ isClientOpen: true })).toBe(true); + }); + + it('returns false when client is closed', () => { + expect(selectIsClientOpen({ isClientOpen: false })).toBe(false); + }); + }); + }); + + describe('lifecycle scenarios', () => { + it('handles multiple open/close cycles', () => { + const { controller, messenger } = createController(); + const listener = jest.fn(); + + messenger.subscribe(`${controllerName}:stateChange`, listener); + + controller.setClientState(true); + controller.setClientState(false); + controller.setClientState(true); + controller.setClientState(false); + + expect(listener).toHaveBeenCalledTimes(4); + expect(controller.isClientOpen).toBe(false); + }); + + it('ignores repeated calls with same value', () => { + const { controller, messenger } = createController(); + const listener = jest.fn(); + + messenger.subscribe(`${controllerName}:stateChange`, listener); + + controller.setClientState(true); + controller.setClientState(true); + controller.setClientState(true); + + expect(listener).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/application-state-controller/src/ApplicationStateController.ts b/packages/application-state-controller/src/ApplicationStateController.ts new file mode 100644 index 00000000000..22946d976cb --- /dev/null +++ b/packages/application-state-controller/src/ApplicationStateController.ts @@ -0,0 +1,235 @@ +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +// === GENERAL === + +/** + * The name of the {@link ApplicationStateController}. + */ +export const controllerName = 'ApplicationStateController'; + +// === STATE === + +/** + * Describes the shape of the state object for {@link ApplicationStateController}. + */ +export type ApplicationStateControllerState = { + /** + * Whether the client (UI) is currently open. + */ + isClientOpen: boolean; +}; + +/** + * Constructs the default {@link ApplicationStateController} state. + * + * @returns The default {@link ApplicationStateController} state. + */ +export function getDefaultApplicationStateControllerState(): ApplicationStateControllerState { + return { + isClientOpen: false, + }; +} + +/** + * The metadata for each property in {@link ApplicationStateControllerState}. + */ +const controllerMetadata = { + isClientOpen: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: false, + }, +} satisfies StateMetadata; + +// === MESSENGER === + +/** + * Retrieves the state of the {@link ApplicationStateController}. + */ +export type ApplicationStateControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + ApplicationStateControllerState +>; + +/** + * Sets whether the client (UI) is open. + */ +export type ApplicationStateControllerSetClientStateAction = { + type: `${typeof controllerName}:setClientState`; + handler: (open: boolean) => void; +}; + +/** + * Actions that {@link ApplicationStateController} exposes. + */ +export type ApplicationStateControllerActions = + | ApplicationStateControllerGetStateAction + | ApplicationStateControllerSetClientStateAction; + +/** + * Actions from other messengers that {@link ApplicationStateController} calls. + */ +type AllowedActions = never; + +/** + * Published when the state of {@link ApplicationStateController} changes. + */ +export type ApplicationStateControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + ApplicationStateControllerState + >; + +/** + * Events that {@link ApplicationStateController} exposes. + */ +export type ApplicationStateControllerEvents = + ApplicationStateControllerStateChangeEvent; + +/** + * Events from other messengers that {@link ApplicationStateController} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger for {@link ApplicationStateController}. + */ +export type ApplicationStateControllerMessenger = Messenger< + typeof controllerName, + ApplicationStateControllerActions | AllowedActions, + ApplicationStateControllerEvents | AllowedEvents +>; + +// === CONTROLLER DEFINITION === + +/** + * The options for constructing an {@link ApplicationStateController}. + */ +export type ApplicationStateControllerOptions = { + /** + * The messenger suited for this controller. + */ + messenger: ApplicationStateControllerMessenger; + /** + * The initial state to set on this controller. + */ + state?: Partial; +}; + +/** + * `ApplicationStateController` manages the application lifecycle state. + * + * This controller tracks whether the client (UI) is open and publishes state + * change events that other controllers can subscribe to for adjusting their behavior. + * + * **Use cases:** + * - Polling controllers can stop when client closes, start when it opens + * - WebSocket connections can disconnect when closed, reconnect when opened + * - Real-time subscriptions can pause when not visible + * + * **Platform Integration:** + * Platform code should call `ApplicationStateController:setClientState` via messenger. + * + * @example + * ```typescript + * // In MetamaskController or platform code + * set isClientOpen(open) { + * this.controllerMessenger.call('ApplicationStateController:setClientState', open); + * } + * + * // Consumer controller subscribing to state changes + * class MyController extends BaseController { + * constructor({ messenger }) { + * super({ messenger, ... }); + * + * messenger.subscribe( + * 'ApplicationStateController:stateChange', + * (newState) => { + * if (newState.isClientOpen) { + * this.startPolling(); + * } else { + * this.stopPolling(); + * } + * }, + * ); + * } + * } + * ``` + */ +export class ApplicationStateController extends BaseController< + typeof controllerName, + ApplicationStateControllerState, + ApplicationStateControllerMessenger +> { + /** + * Constructs a new {@link ApplicationStateController}. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this controller. + * @param options.state - The initial state to set on this controller. + */ + constructor({ messenger, state = {} }: ApplicationStateControllerOptions) { + super({ + messenger, + metadata: controllerMetadata, + name: controllerName, + state: { + ...getDefaultApplicationStateControllerState(), + ...state, + }, + }); + + // Register the setClientState action + this.messenger.registerActionHandler( + `${controllerName}:setClientState`, + this.setClientState.bind(this), + ); + } + + /** + * Sets whether the client (UI) is open. + * + * This method should be called via messenger when the UI opens or closes. + * State changes trigger the standard `stateChange` event that other controllers + * can subscribe to. + * + * @param open - Whether the client is open. + */ + setClientState(open: boolean): void { + if (this.state.isClientOpen !== open) { + this.update((state) => { + state.isClientOpen = open; + }); + } + } + + /** + * Returns whether the client is currently open. + * + * @returns True if the client is open. + */ + get isClientOpen(): boolean { + return this.state.isClientOpen; + } +} + +// === SELECTORS === + +/** + * Selects whether the client is currently open. + * + * @param state - The ApplicationStateController state. + * @returns True if the client is open. + */ +export function selectIsClientOpen( + state: ApplicationStateControllerState, +): boolean { + return state.isClientOpen; +} diff --git a/packages/application-state-controller/src/index.ts b/packages/application-state-controller/src/index.ts new file mode 100644 index 00000000000..7abd35f556f --- /dev/null +++ b/packages/application-state-controller/src/index.ts @@ -0,0 +1,17 @@ +export { + ApplicationStateController, + controllerName, + getDefaultApplicationStateControllerState, + selectIsClientOpen, +} from './ApplicationStateController'; + +export type { + ApplicationStateControllerState, + ApplicationStateControllerOptions, + ApplicationStateControllerGetStateAction, + ApplicationStateControllerSetClientStateAction, + ApplicationStateControllerActions, + ApplicationStateControllerStateChangeEvent, + ApplicationStateControllerEvents, + ApplicationStateControllerMessenger, +} from './ApplicationStateController'; diff --git a/packages/application-state-controller/tsconfig.build.json b/packages/application-state-controller/tsconfig.build.json new file mode 100644 index 00000000000..249f327913d --- /dev/null +++ b/packages/application-state-controller/tsconfig.build.json @@ -0,0 +1,17 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { + "path": "../base-controller/tsconfig.build.json" + }, + { + "path": "../messenger/tsconfig.build.json" + } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/application-state-controller/tsconfig.json b/packages/application-state-controller/tsconfig.json new file mode 100644 index 00000000000..00621e3ad95 --- /dev/null +++ b/packages/application-state-controller/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "." + }, + "include": ["src"] +} diff --git a/packages/application-state-controller/typedoc.json b/packages/application-state-controller/typedoc.json new file mode 100644 index 00000000000..d02905868c6 --- /dev/null +++ b/packages/application-state-controller/typedoc.json @@ -0,0 +1,8 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "plugin": ["typedoc-plugin-missing-exports"], + "tsconfig": "./tsconfig.build.json" +} diff --git a/teams.json b/teams.json index 9e0811957fe..b9ec8b9f00e 100644 --- a/teams.json +++ b/teams.json @@ -35,6 +35,7 @@ "metamask/multichain-api-middleware": "team-wallet-integrations", "metamask/selected-network-controller": "team-wallet-integrations", "metamask/eip-5792-middleware": "team-wallet-integrations", + "metamask/application-state-controller": "team-core-platform", "metamask/base-controller": "team-core-platform", "metamask/build-utils": "team-core-platform", "metamask/composable-controller": "team-core-platform", diff --git a/yarn.lock b/yarn.lock index a62b791a03f..fc0610bf294 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2485,6 +2485,24 @@ __metadata: languageName: unknown linkType: soft +"@metamask/application-state-controller@workspace:packages/application-state-controller": + version: 0.0.0-use.local + resolution: "@metamask/application-state-controller@workspace:packages/application-state-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.5.2" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.5" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/approval-controller@npm:^8.0.0, @metamask/approval-controller@workspace:packages/approval-controller": version: 0.0.0-use.local resolution: "@metamask/approval-controller@workspace:packages/approval-controller" From 03c1407bde166343e21de78a631052d73512fe05 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Tue, 3 Feb 2026 17:17:34 +0100 Subject: [PATCH 2/7] fix: code --- .../application-state-controller/CHANGELOG.md | 2 +- packages/application-state-controller/LICENSE | 2 +- .../application-state-controller/README.md | 10 ++-- .../application-state-controller/package.json | 2 +- .../src/ApplicationStateController.test.ts | 48 +++++++++---------- .../src/ApplicationStateController.ts | 18 +++---- 6 files changed, 41 insertions(+), 41 deletions(-) diff --git a/packages/application-state-controller/CHANGELOG.md b/packages/application-state-controller/CHANGELOG.md index df71463ce90..cf398fd658c 100644 --- a/packages/application-state-controller/CHANGELOG.md +++ b/packages/application-state-controller/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Initial release of `@metamask/application-state-controller` ([#7808](https://github.com/MetaMask/core/pull/7808)) - `ApplicationStateController` for managing client (UI) open/closed state - - `ApplicationStateController:setClientState` messenger action for platform code to call + - `ApplicationStateController:setClientOpen` messenger action for platform code to call - `ApplicationStateController:stateChange` event for controllers to subscribe to lifecycle changes - `isClientOpen` state property (not persisted - always starts as `false`) - `selectIsClientOpen` selector for derived state access diff --git a/packages/application-state-controller/LICENSE b/packages/application-state-controller/LICENSE index 37484ffd950..fe29e78e0fe 100644 --- a/packages/application-state-controller/LICENSE +++ b/packages/application-state-controller/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2024 MetaMask +Copyright (c) 2026 MetaMask Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/packages/application-state-controller/README.md b/packages/application-state-controller/README.md index 502bfd48d08..14ad8de13c8 100644 --- a/packages/application-state-controller/README.md +++ b/packages/application-state-controller/README.md @@ -4,7 +4,7 @@ Manages application lifecycle state (client open/closed) for cross-platform Meta ## Overview -The `ApplicationStateController` provides a centralized way for controllers to respond to application lifecycle changes. Platform code calls `ApplicationStateController:setClientState` via messenger, and other controllers subscribe to `stateChange` events. +The `ApplicationStateController` provides a centralized way for controllers to respond to application lifecycle changes. Platform code calls `ApplicationStateController:setClientOpen` via messenger, and other controllers subscribe to `stateChange` events. ### The Problem It Solves @@ -31,7 +31,7 @@ With `ApplicationStateController`, controllers manage themselves: ```typescript // Platform code calls the controller via messenger set isClientOpen(open) { - this.controllerMessenger.call('ApplicationStateController:setClientState', open); + this.controllerMessenger.call('ApplicationStateController:setClientOpen', open); } // Controllers subscribe to stateChange and manage themselves @@ -92,7 +92,7 @@ class MetamaskController { // Platform calls this when UI opens/closes set isClientOpen(open) { this.controllerMessenger.call( - 'ApplicationStateController:setClientState', + 'ApplicationStateController:setClientOpen', open, ); } @@ -110,7 +110,7 @@ AppState.addEventListener('change', (nextAppState) => { return; } controllerMessenger.call( - 'ApplicationStateController:setClientState', + 'ApplicationStateController:setClientOpen', nextAppState === 'active', ); }); @@ -197,7 +197,7 @@ Note: State is not persisted. It always starts as `false`. | Action | Parameters | Description | | ------------------------------------------- | --------------- | -------------------------------- | | `ApplicationStateController:getState` | none | Returns current state. | -| `ApplicationStateController:setClientState` | `open: boolean` | Sets whether the client is open. | +| `ApplicationStateController:setClientOpen` | `open: boolean` | Sets whether the client is open. | ### Events diff --git a/packages/application-state-controller/package.json b/packages/application-state-controller/package.json index 1d668b0a1f5..00aa1f331af 100644 --- a/packages/application-state-controller/package.json +++ b/packages/application-state-controller/package.json @@ -1,6 +1,6 @@ { "name": "@metamask/application-state-controller", - "version": "0.1.0", + "version": "0.0.0", "description": "Manages application lifecycle state (client open/closed) for cross-platform MetaMask applications", "keywords": [ "MetaMask", diff --git a/packages/application-state-controller/src/ApplicationStateController.test.ts b/packages/application-state-controller/src/ApplicationStateController.test.ts index fe79a4d3d89..3bb5b70a7dc 100644 --- a/packages/application-state-controller/src/ApplicationStateController.test.ts +++ b/packages/application-state-controller/src/ApplicationStateController.test.ts @@ -84,11 +84,11 @@ describe('ApplicationStateController', () => { }); }); - describe('setClientState', () => { + describe('setClientOpen', () => { it('updates state when client opens', () => { const { controller } = createController(); - controller.setClientState(true); + controller.setClientOpen(true); expect(controller.state.isClientOpen).toBe(true); expect(controller.isClientOpen).toBe(true); @@ -96,9 +96,9 @@ describe('ApplicationStateController', () => { it('updates state when client closes', () => { const { controller } = createController(); - controller.setClientState(true); + controller.setClientOpen(true); - controller.setClientState(false); + controller.setClientOpen(false); expect(controller.state.isClientOpen).toBe(false); expect(controller.isClientOpen).toBe(false); @@ -106,11 +106,11 @@ describe('ApplicationStateController', () => { it('does not update state when setting the same value', () => { const { controller, messenger } = createController(); - controller.setClientState(true); + controller.setClientOpen(true); const listener = jest.fn(); messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientState(true); + controller.setClientOpen(true); expect(listener).not.toHaveBeenCalled(); }); @@ -120,7 +120,7 @@ describe('ApplicationStateController', () => { const listener = jest.fn(); messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientState(true); + controller.setClientOpen(true); expect(listener).toHaveBeenCalledTimes(1); const [newState] = listener.mock.calls[0]; @@ -129,11 +129,11 @@ describe('ApplicationStateController', () => { it('publishes stateChange event when client closes', () => { const { controller, messenger } = createController(); - controller.setClientState(true); + controller.setClientOpen(true); const listener = jest.fn(); messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientState(false); + controller.setClientOpen(false); expect(listener).toHaveBeenCalledTimes(1); const [newState] = listener.mock.calls[0]; @@ -142,11 +142,11 @@ describe('ApplicationStateController', () => { it('does not publish stateChange when state does not change', () => { const { controller, messenger } = createController(); - controller.setClientState(true); + controller.setClientOpen(true); const listener = jest.fn(); messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientState(true); + controller.setClientOpen(true); expect(listener).not.toHaveBeenCalled(); }); @@ -155,7 +155,7 @@ describe('ApplicationStateController', () => { describe('isClientOpen getter', () => { it('returns true when client is open', () => { const { controller } = createController(); - controller.setClientState(true); + controller.setClientOpen(true); expect(controller.isClientOpen).toBe(true); }); @@ -170,7 +170,7 @@ describe('ApplicationStateController', () => { describe('messenger actions', () => { it('allows getting state via messenger action', () => { const { controller, messenger } = createController(); - controller.setClientState(true); + controller.setClientOpen(true); const state = messenger.call(`${controllerName}:getState`); @@ -180,16 +180,16 @@ describe('ApplicationStateController', () => { it('allows setting client open via messenger action', () => { const { controller, messenger } = createController(); - messenger.call(`${controllerName}:setClientState`, true); + messenger.call(`${controllerName}:setClientOpen`, true); expect(controller.state.isClientOpen).toBe(true); }); it('allows setting client closed via messenger action', () => { const { controller, messenger } = createController(); - controller.setClientState(true); + controller.setClientOpen(true); - messenger.call(`${controllerName}:setClientState`, false); + messenger.call(`${controllerName}:setClientOpen`, false); expect(controller.state.isClientOpen).toBe(false); }); @@ -199,7 +199,7 @@ describe('ApplicationStateController', () => { const listener = jest.fn(); messenger.subscribe(`${controllerName}:stateChange`, listener); - messenger.call(`${controllerName}:setClientState`, true); + messenger.call(`${controllerName}:setClientOpen`, true); expect(listener).toHaveBeenCalledTimes(1); const [newState] = listener.mock.calls[0]; @@ -234,10 +234,10 @@ describe('ApplicationStateController', () => { messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientState(true); - controller.setClientState(false); - controller.setClientState(true); - controller.setClientState(false); + controller.setClientOpen(true); + controller.setClientOpen(false); + controller.setClientOpen(true); + controller.setClientOpen(false); expect(listener).toHaveBeenCalledTimes(4); expect(controller.isClientOpen).toBe(false); @@ -249,9 +249,9 @@ describe('ApplicationStateController', () => { messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientState(true); - controller.setClientState(true); - controller.setClientState(true); + controller.setClientOpen(true); + controller.setClientOpen(true); + controller.setClientOpen(true); expect(listener).toHaveBeenCalledTimes(1); }); diff --git a/packages/application-state-controller/src/ApplicationStateController.ts b/packages/application-state-controller/src/ApplicationStateController.ts index 22946d976cb..e2c0f2e0708 100644 --- a/packages/application-state-controller/src/ApplicationStateController.ts +++ b/packages/application-state-controller/src/ApplicationStateController.ts @@ -61,8 +61,8 @@ export type ApplicationStateControllerGetStateAction = ControllerGetStateAction< /** * Sets whether the client (UI) is open. */ -export type ApplicationStateControllerSetClientStateAction = { - type: `${typeof controllerName}:setClientState`; +export type ApplicationStateControllerSetClientOpenAction = { + type: `${typeof controllerName}:setClientOpen`; handler: (open: boolean) => void; }; @@ -71,7 +71,7 @@ export type ApplicationStateControllerSetClientStateAction = { */ export type ApplicationStateControllerActions = | ApplicationStateControllerGetStateAction - | ApplicationStateControllerSetClientStateAction; + | ApplicationStateControllerSetClientOpenAction; /** * Actions from other messengers that {@link ApplicationStateController} calls. @@ -135,13 +135,13 @@ export type ApplicationStateControllerOptions = { * - Real-time subscriptions can pause when not visible * * **Platform Integration:** - * Platform code should call `ApplicationStateController:setClientState` via messenger. + * Platform code should call `ApplicationStateController:setClientOpen` via messenger. * * @example * ```typescript * // In MetamaskController or platform code * set isClientOpen(open) { - * this.controllerMessenger.call('ApplicationStateController:setClientState', open); + * this.controllerMessenger.call('ApplicationStateController:setClientOpen', open); * } * * // Consumer controller subscribing to state changes @@ -186,10 +186,10 @@ export class ApplicationStateController extends BaseController< }, }); - // Register the setClientState action + // Register the setClientOpen action this.messenger.registerActionHandler( - `${controllerName}:setClientState`, - this.setClientState.bind(this), + `${controllerName}:setClientOpen`, + this.setClientOpen.bind(this), ); } @@ -202,7 +202,7 @@ export class ApplicationStateController extends BaseController< * * @param open - Whether the client is open. */ - setClientState(open: boolean): void { + setClientOpen(open: boolean): void { if (this.state.isClientOpen !== open) { this.update((state) => { state.isClientOpen = open; From 86c95e935d4f5c715e8041c78f2be791ba46333f Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Thu, 5 Feb 2026 11:14:11 +0100 Subject: [PATCH 3/7] chore: rename to ClientStateController --- .github/CODEOWNERS | 2 +- .../application-state-controller/package.json | 72 --------------- .../application-state-controller/src/index.ts | 17 ---- .../tsconfig.build.json | 17 ---- .../tsconfig.json | 7 -- .../application-state-controller/typedoc.json | 8 -- .../CHANGELOG.md | 8 +- .../LICENSE | 0 .../README.md | 58 ++++++------ .../jest.config.js | 0 packages/client-state-controller/package.json | 1 + .../src/ClientStateController.test.ts} | 44 ++++----- .../src/ClientStateController.ts} | 90 +++++++++---------- packages/client-state-controller/src/index.ts | 17 ++++ .../tsconfig.build.json | 1 + .../client-state-controller/tsconfig.json | 1 + packages/client-state-controller/typedoc.json | 1 + teams.json | 2 +- yarn.lock | 4 +- 19 files changed, 125 insertions(+), 225 deletions(-) delete mode 100644 packages/application-state-controller/package.json delete mode 100644 packages/application-state-controller/src/index.ts delete mode 100644 packages/application-state-controller/tsconfig.build.json delete mode 100644 packages/application-state-controller/tsconfig.json delete mode 100644 packages/application-state-controller/typedoc.json rename packages/{application-state-controller => client-state-controller}/CHANGELOG.md (57%) rename packages/{application-state-controller => client-state-controller}/LICENSE (100%) rename packages/{application-state-controller => client-state-controller}/README.md (62%) rename packages/{application-state-controller => client-state-controller}/jest.config.js (100%) create mode 100644 packages/client-state-controller/package.json rename packages/{application-state-controller/src/ApplicationStateController.test.ts => client-state-controller/src/ClientStateController.test.ts} (87%) rename packages/{application-state-controller/src/ApplicationStateController.ts => client-state-controller/src/ClientStateController.ts} (56%) create mode 100644 packages/client-state-controller/src/index.ts create mode 100644 packages/client-state-controller/tsconfig.build.json create mode 100644 packages/client-state-controller/tsconfig.json create mode 100644 packages/client-state-controller/typedoc.json diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index ed6c489d909..44184932578 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,7 +71,7 @@ /packages/eip-5792-middleware @MetaMask/wallet-integrations ## Core Platform Team -/packages/application-state-controller @MetaMask/core-platform +/packages/client-state-controller @MetaMask/core-platform /packages/base-controller @MetaMask/core-platform /packages/build-utils @MetaMask/core-platform /packages/composable-controller @MetaMask/core-platform diff --git a/packages/application-state-controller/package.json b/packages/application-state-controller/package.json deleted file mode 100644 index 00aa1f331af..00000000000 --- a/packages/application-state-controller/package.json +++ /dev/null @@ -1,72 +0,0 @@ -{ - "name": "@metamask/application-state-controller", - "version": "0.0.0", - "description": "Manages application lifecycle state (client open/closed) for cross-platform MetaMask applications", - "keywords": [ - "MetaMask", - "Ethereum" - ], - "homepage": "https://github.com/MetaMask/core/tree/main/packages/application-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/application-state-controller", - "changelog:validate": "../../scripts/validate-changelog.sh @metamask/application-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/" - } -} diff --git a/packages/application-state-controller/src/index.ts b/packages/application-state-controller/src/index.ts deleted file mode 100644 index 7abd35f556f..00000000000 --- a/packages/application-state-controller/src/index.ts +++ /dev/null @@ -1,17 +0,0 @@ -export { - ApplicationStateController, - controllerName, - getDefaultApplicationStateControllerState, - selectIsClientOpen, -} from './ApplicationStateController'; - -export type { - ApplicationStateControllerState, - ApplicationStateControllerOptions, - ApplicationStateControllerGetStateAction, - ApplicationStateControllerSetClientStateAction, - ApplicationStateControllerActions, - ApplicationStateControllerStateChangeEvent, - ApplicationStateControllerEvents, - ApplicationStateControllerMessenger, -} from './ApplicationStateController'; diff --git a/packages/application-state-controller/tsconfig.build.json b/packages/application-state-controller/tsconfig.build.json deleted file mode 100644 index 249f327913d..00000000000 --- a/packages/application-state-controller/tsconfig.build.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "extends": "../../tsconfig.packages.build.json", - "compilerOptions": { - "baseUrl": "./", - "outDir": "./dist", - "rootDir": "./src" - }, - "references": [ - { - "path": "../base-controller/tsconfig.build.json" - }, - { - "path": "../messenger/tsconfig.build.json" - } - ], - "include": ["../../types", "./src"] -} diff --git a/packages/application-state-controller/tsconfig.json b/packages/application-state-controller/tsconfig.json deleted file mode 100644 index 00621e3ad95..00000000000 --- a/packages/application-state-controller/tsconfig.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "extends": "../../tsconfig.packages.json", - "compilerOptions": { - "baseUrl": "." - }, - "include": ["src"] -} diff --git a/packages/application-state-controller/typedoc.json b/packages/application-state-controller/typedoc.json deleted file mode 100644 index d02905868c6..00000000000 --- a/packages/application-state-controller/typedoc.json +++ /dev/null @@ -1,8 +0,0 @@ -{ - "entryPoints": ["./src/index.ts"], - "excludePrivate": true, - "hideGenerator": true, - "out": "docs", - "plugin": ["typedoc-plugin-missing-exports"], - "tsconfig": "./tsconfig.build.json" -} diff --git a/packages/application-state-controller/CHANGELOG.md b/packages/client-state-controller/CHANGELOG.md similarity index 57% rename from packages/application-state-controller/CHANGELOG.md rename to packages/client-state-controller/CHANGELOG.md index cf398fd658c..fef60c15109 100644 --- a/packages/application-state-controller/CHANGELOG.md +++ b/packages/client-state-controller/CHANGELOG.md @@ -9,10 +9,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release of `@metamask/application-state-controller` ([#7808](https://github.com/MetaMask/core/pull/7808)) - - `ApplicationStateController` for managing client (UI) open/closed state - - `ApplicationStateController:setClientOpen` messenger action for platform code to call - - `ApplicationStateController:stateChange` event for controllers to subscribe to lifecycle changes +- Initial release of `@metamask/client-state-controller` (renamed from `@metamask/application-state-controller`) + - `ClientStateController` for managing client (UI) open/closed state + - `ClientStateController:setClientOpen` messenger action for platform code to call + - `ClientStateController:stateChange` event for controllers to subscribe to lifecycle changes - `isClientOpen` state property (not persisted - always starts as `false`) - `selectIsClientOpen` selector for derived state access - Full TypeScript support with exported types diff --git a/packages/application-state-controller/LICENSE b/packages/client-state-controller/LICENSE similarity index 100% rename from packages/application-state-controller/LICENSE rename to packages/client-state-controller/LICENSE diff --git a/packages/application-state-controller/README.md b/packages/client-state-controller/README.md similarity index 62% rename from packages/application-state-controller/README.md rename to packages/client-state-controller/README.md index 14ad8de13c8..a4d42816c17 100644 --- a/packages/application-state-controller/README.md +++ b/packages/client-state-controller/README.md @@ -1,10 +1,10 @@ -# `@metamask/application-state-controller` +# `@metamask/client-state-controller` -Manages application lifecycle state (client open/closed) for cross-platform MetaMask applications. +Manages client lifecycle state (client open/closed) for cross-platform MetaMask applications. ## Overview -The `ApplicationStateController` provides a centralized way for controllers to respond to application lifecycle changes. Platform code calls `ApplicationStateController:setClientOpen` via messenger, and other controllers subscribe to `stateChange` events. +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. ### The Problem It Solves @@ -26,18 +26,18 @@ set isClientOpen(open) { ### The Solution -With `ApplicationStateController`, controllers manage themselves: +With `ClientStateController`, controllers manage themselves: ```typescript // Platform code calls the controller via messenger set isClientOpen(open) { - this.controllerMessenger.call('ApplicationStateController:setClientOpen', open); + this.controllerMessenger.call('ClientStateController:setClientOpen', open); } // Controllers subscribe to stateChange and manage themselves class MyController extends BaseController { constructor({ messenger }) { - messenger.subscribe('ApplicationStateController:stateChange', (newState) => { + messenger.subscribe('ClientStateController:stateChange', (newState) => { if (newState.isClientOpen) { this.start(); } else { @@ -50,11 +50,11 @@ class MyController extends BaseController { ## Installation -`yarn add @metamask/application-state-controller` +`yarn add @metamask/client-state-controller` or -`npm install @metamask/application-state-controller` +`npm install @metamask/client-state-controller` ## Usage @@ -63,23 +63,23 @@ or ```typescript import { Messenger } from '@metamask/messenger'; import { - ApplicationStateController, - ApplicationStateControllerActions, - ApplicationStateControllerEvents, -} from '@metamask/application-state-controller'; + ClientStateController, + ClientStateControllerActions, + ClientStateControllerEvents, +} from '@metamask/client-state-controller'; const rootMessenger = new Messenger< 'Root', - ApplicationStateControllerActions, - ApplicationStateControllerEvents + ClientStateControllerActions, + ClientStateControllerEvents >({ namespace: 'Root' }); const controllerMessenger = new Messenger({ - namespace: 'ApplicationStateController', + namespace: 'ClientStateController', parent: rootMessenger, }); -const applicationStateController = new ApplicationStateController({ +const clientStateController = new ClientStateController({ messenger: controllerMessenger, }); ``` @@ -92,7 +92,7 @@ class MetamaskController { // Platform calls this when UI opens/closes set isClientOpen(open) { this.controllerMessenger.call( - 'ApplicationStateController:setClientOpen', + 'ClientStateController:setClientOpen', open, ); } @@ -110,7 +110,7 @@ AppState.addEventListener('change', (nextAppState) => { return; } controllerMessenger.call( - 'ApplicationStateController:setClientOpen', + 'ClientStateController:setClientOpen', nextAppState === 'active', ); }); @@ -125,7 +125,7 @@ class TokenBalancesController extends BaseController { // Subscribe to lifecycle state changes this.messenger.subscribe( - 'ApplicationStateController:stateChange', + 'ClientStateController:stateChange', (newState) => { if (newState.isClientOpen) { this.startPolling(); @@ -156,7 +156,7 @@ class WebSocketController extends BaseController { super({ messenger, ... }); messenger.subscribe( - 'ApplicationStateController:stateChange', + 'ClientStateController:stateChange', (newState) => { if (newState.isClientOpen) { this.connect(); @@ -194,23 +194,23 @@ Note: State is not persisted. It always starts as `false`. ### Actions -| Action | Parameters | Description | -| ------------------------------------------- | --------------- | -------------------------------- | -| `ApplicationStateController:getState` | none | Returns current state. | -| `ApplicationStateController:setClientOpen` | `open: boolean` | Sets whether the client is open. | +| Action | Parameters | Description | +| ----------------------------------------- | --------------- | -------------------------------- | +| `ClientStateController:getState` | none | Returns current state. | +| `ClientStateController:setClientOpen` | `open: boolean` | Sets whether the client is open. | ### Events -| Event | Payload | Description | -| ---------------------------------------- | ------------------ | ---------------------------- | -| `ApplicationStateController:stateChange` | `[state, patches]` | Standard state change event. | +| Event | Payload | Description | +| -------------------------------------- | ------------------ | ---------------------------- | +| `ClientStateController:stateChange` | `[state, patches]` | Standard state change event. | ### Selectors ```typescript -import { selectIsClientOpen } from '@metamask/application-state-controller'; +import { selectIsClientOpen } from '@metamask/client-state-controller'; -const state = messenger.call('ApplicationStateController:getState'); +const state = messenger.call('ClientStateController:getState'); const isOpen = selectIsClientOpen(state); ``` diff --git a/packages/application-state-controller/jest.config.js b/packages/client-state-controller/jest.config.js similarity index 100% rename from packages/application-state-controller/jest.config.js rename to packages/client-state-controller/jest.config.js diff --git a/packages/client-state-controller/package.json b/packages/client-state-controller/package.json new file mode 100644 index 00000000000..608e210dffe --- /dev/null +++ b/packages/client-state-controller/package.json @@ -0,0 +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/"}} diff --git a/packages/application-state-controller/src/ApplicationStateController.test.ts b/packages/client-state-controller/src/ClientStateController.test.ts similarity index 87% rename from packages/application-state-controller/src/ApplicationStateController.test.ts rename to packages/client-state-controller/src/ClientStateController.test.ts index 3bb5b70a7dc..d836c50d1a5 100644 --- a/packages/application-state-controller/src/ApplicationStateController.test.ts +++ b/packages/client-state-controller/src/ClientStateController.test.ts @@ -1,35 +1,35 @@ import { Messenger } from '@metamask/messenger'; import type { - ApplicationStateControllerActions, - ApplicationStateControllerEvents, - ApplicationStateControllerMessenger, - ApplicationStateControllerState, -} from './ApplicationStateController'; + ClientStateControllerActions, + ClientStateControllerEvents, + ClientStateControllerMessenger, + ClientStateControllerState, +} from './ClientStateController'; import { - ApplicationStateController, + ClientStateController, controllerName, - getDefaultApplicationStateControllerState, + getDefaultClientStateControllerState, selectIsClientOpen, -} from './ApplicationStateController'; +} from './ClientStateController'; -describe('ApplicationStateController', () => { +describe('ClientStateController', () => { /** - * Creates a messenger for the ApplicationStateController. + * Creates a messenger for the ClientStateController. * * @returns A messenger for the controller. */ - function createMessenger(): ApplicationStateControllerMessenger { + function createMessenger(): ClientStateControllerMessenger { const rootMessenger = new Messenger< 'Root', - ApplicationStateControllerActions, - ApplicationStateControllerEvents + ClientStateControllerActions, + ClientStateControllerEvents >({ namespace: 'Root' }); return new Messenger< typeof controllerName, - ApplicationStateControllerActions, - ApplicationStateControllerEvents, + ClientStateControllerActions, + ClientStateControllerEvents, typeof rootMessenger >({ namespace: controllerName, @@ -38,20 +38,20 @@ describe('ApplicationStateController', () => { } /** - * Creates an ApplicationStateController. + * Creates a ClientStateController. * * @param options - Options for creating the controller. * @param options.state - Initial state to set on the controller. * @returns The controller and messenger. */ function createController(options?: { - state?: Partial; + state?: Partial; }): { - controller: ApplicationStateController; - messenger: ApplicationStateControllerMessenger; + controller: ClientStateController; + messenger: ClientStateControllerMessenger; } { const messenger = createMessenger(); - const controller = new ApplicationStateController({ + const controller = new ClientStateController({ messenger, state: options?.state, }); @@ -207,9 +207,9 @@ describe('ApplicationStateController', () => { }); }); - describe('getDefaultApplicationStateControllerState', () => { + describe('getDefaultClientStateControllerState', () => { it('returns default state with client closed', () => { - const defaultState = getDefaultApplicationStateControllerState(); + const defaultState = getDefaultClientStateControllerState(); expect(defaultState.isClientOpen).toBe(false); }); diff --git a/packages/application-state-controller/src/ApplicationStateController.ts b/packages/client-state-controller/src/ClientStateController.ts similarity index 56% rename from packages/application-state-controller/src/ApplicationStateController.ts rename to packages/client-state-controller/src/ClientStateController.ts index e2c0f2e0708..8cecd254bd8 100644 --- a/packages/application-state-controller/src/ApplicationStateController.ts +++ b/packages/client-state-controller/src/ClientStateController.ts @@ -9,16 +9,16 @@ import type { Messenger } from '@metamask/messenger'; // === GENERAL === /** - * The name of the {@link ApplicationStateController}. + * The name of the {@link ClientStateController}. */ -export const controllerName = 'ApplicationStateController'; +export const controllerName = 'ClientStateController'; // === STATE === /** - * Describes the shape of the state object for {@link ApplicationStateController}. + * Describes the shape of the state object for {@link ClientStateController}. */ -export type ApplicationStateControllerState = { +export type ClientStateControllerState = { /** * Whether the client (UI) is currently open. */ @@ -26,18 +26,18 @@ export type ApplicationStateControllerState = { }; /** - * Constructs the default {@link ApplicationStateController} state. + * Constructs the default {@link ClientStateController} state. * - * @returns The default {@link ApplicationStateController} state. + * @returns The default {@link ClientStateController} state. */ -export function getDefaultApplicationStateControllerState(): ApplicationStateControllerState { +export function getDefaultClientStateControllerState(): ClientStateControllerState { return { isClientOpen: false, }; } /** - * The metadata for each property in {@link ApplicationStateControllerState}. + * The metadata for each property in {@link ClientStateControllerState}. */ const controllerMetadata = { isClientOpen: { @@ -46,85 +46,85 @@ const controllerMetadata = { persist: false, usedInUi: false, }, -} satisfies StateMetadata; +} satisfies StateMetadata; // === MESSENGER === /** - * Retrieves the state of the {@link ApplicationStateController}. + * Retrieves the state of the {@link ClientStateController}. */ -export type ApplicationStateControllerGetStateAction = ControllerGetStateAction< +export type ClientStateControllerGetStateAction = ControllerGetStateAction< typeof controllerName, - ApplicationStateControllerState + ClientStateControllerState >; /** * Sets whether the client (UI) is open. */ -export type ApplicationStateControllerSetClientOpenAction = { +export type ClientStateControllerSetClientOpenAction = { type: `${typeof controllerName}:setClientOpen`; handler: (open: boolean) => void; }; /** - * Actions that {@link ApplicationStateController} exposes. + * Actions that {@link ClientStateController} exposes. */ -export type ApplicationStateControllerActions = - | ApplicationStateControllerGetStateAction - | ApplicationStateControllerSetClientOpenAction; +export type ClientStateControllerActions = + | ClientStateControllerGetStateAction + | ClientStateControllerSetClientOpenAction; /** - * Actions from other messengers that {@link ApplicationStateController} calls. + * Actions from other messengers that {@link ClientStateController} calls. */ type AllowedActions = never; /** - * Published when the state of {@link ApplicationStateController} changes. + * Published when the state of {@link ClientStateController} changes. */ -export type ApplicationStateControllerStateChangeEvent = +export type ClientStateControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, - ApplicationStateControllerState + ClientStateControllerState >; /** - * Events that {@link ApplicationStateController} exposes. + * Events that {@link ClientStateController} exposes. */ -export type ApplicationStateControllerEvents = - ApplicationStateControllerStateChangeEvent; +export type ClientStateControllerEvents = + ClientStateControllerStateChangeEvent; /** - * Events from other messengers that {@link ApplicationStateController} subscribes to. + * Events from other messengers that {@link ClientStateController} subscribes to. */ type AllowedEvents = never; /** - * The messenger for {@link ApplicationStateController}. + * The messenger for {@link ClientStateController}. */ -export type ApplicationStateControllerMessenger = Messenger< +export type ClientStateControllerMessenger = Messenger< typeof controllerName, - ApplicationStateControllerActions | AllowedActions, - ApplicationStateControllerEvents | AllowedEvents + ClientStateControllerActions | AllowedActions, + ClientStateControllerEvents | AllowedEvents >; // === CONTROLLER DEFINITION === /** - * The options for constructing an {@link ApplicationStateController}. + * The options for constructing a {@link ClientStateController}. */ -export type ApplicationStateControllerOptions = { +export type ClientStateControllerOptions = { /** * The messenger suited for this controller. */ - messenger: ApplicationStateControllerMessenger; + messenger: ClientStateControllerMessenger; /** * The initial state to set on this controller. */ - state?: Partial; + state?: Partial; }; /** - * `ApplicationStateController` manages the application lifecycle state. + * `ClientStateController` manages the application lifecycle state. * * This controller tracks whether the client (UI) is open and publishes state * change events that other controllers can subscribe to for adjusting their behavior. @@ -135,13 +135,13 @@ export type ApplicationStateControllerOptions = { * - Real-time subscriptions can pause when not visible * * **Platform Integration:** - * Platform code should call `ApplicationStateController:setClientOpen` via messenger. + * Platform code should call `ClientStateController:setClientOpen` via messenger. * * @example * ```typescript * // In MetamaskController or platform code * set isClientOpen(open) { - * this.controllerMessenger.call('ApplicationStateController:setClientOpen', open); + * this.controllerMessenger.call('ClientStateController:setClientOpen', open); * } * * // Consumer controller subscribing to state changes @@ -150,7 +150,7 @@ export type ApplicationStateControllerOptions = { * super({ messenger, ... }); * * messenger.subscribe( - * 'ApplicationStateController:stateChange', + * 'ClientStateController:stateChange', * (newState) => { * if (newState.isClientOpen) { * this.startPolling(); @@ -163,25 +163,25 @@ export type ApplicationStateControllerOptions = { * } * ``` */ -export class ApplicationStateController extends BaseController< +export class ClientStateController extends BaseController< typeof controllerName, - ApplicationStateControllerState, - ApplicationStateControllerMessenger + ClientStateControllerState, + ClientStateControllerMessenger > { /** - * Constructs a new {@link ApplicationStateController}. + * Constructs a new {@link ClientStateController}. * * @param options - The constructor options. * @param options.messenger - The messenger suited for this controller. * @param options.state - The initial state to set on this controller. */ - constructor({ messenger, state = {} }: ApplicationStateControllerOptions) { + constructor({ messenger, state = {} }: ClientStateControllerOptions) { super({ messenger, metadata: controllerMetadata, name: controllerName, state: { - ...getDefaultApplicationStateControllerState(), + ...getDefaultClientStateControllerState(), ...state, }, }); @@ -225,11 +225,11 @@ export class ApplicationStateController extends BaseController< /** * Selects whether the client is currently open. * - * @param state - The ApplicationStateController state. + * @param state - The ClientStateController state. * @returns True if the client is open. */ export function selectIsClientOpen( - state: ApplicationStateControllerState, + state: ClientStateControllerState, ): boolean { return state.isClientOpen; } diff --git a/packages/client-state-controller/src/index.ts b/packages/client-state-controller/src/index.ts new file mode 100644 index 00000000000..af583c66efe --- /dev/null +++ b/packages/client-state-controller/src/index.ts @@ -0,0 +1,17 @@ +export { + ClientStateController, + controllerName, + getDefaultClientStateControllerState, + selectIsClientOpen, +} from './ClientStateController'; + +export type { + ClientStateControllerState, + ClientStateControllerOptions, + ClientStateControllerGetStateAction, + ClientStateControllerSetClientOpenAction, + ClientStateControllerActions, + ClientStateControllerStateChangeEvent, + ClientStateControllerEvents, + ClientStateControllerMessenger, +} from './ClientStateController'; diff --git a/packages/client-state-controller/tsconfig.build.json b/packages/client-state-controller/tsconfig.build.json new file mode 100644 index 00000000000..86e6fcd1303 --- /dev/null +++ b/packages/client-state-controller/tsconfig.build.json @@ -0,0 +1 @@ +{"extends":"../../tsconfig.packages.build.json","compilerOptions":{"baseUrl":"./","outDir":"./dist","rootDir":"./src"},"references":[{"path":"../base-controller/tsconfig.build.json"},{"path":"../messenger/tsconfig.build.json"}],"include":["../../types","./src"]} diff --git a/packages/client-state-controller/tsconfig.json b/packages/client-state-controller/tsconfig.json new file mode 100644 index 00000000000..db784ac0317 --- /dev/null +++ b/packages/client-state-controller/tsconfig.json @@ -0,0 +1 @@ +{"extends":"../../tsconfig.packages.json","compilerOptions":{"baseUrl":"."},"include":["src"]} diff --git a/packages/client-state-controller/typedoc.json b/packages/client-state-controller/typedoc.json new file mode 100644 index 00000000000..d7395bbc67e --- /dev/null +++ b/packages/client-state-controller/typedoc.json @@ -0,0 +1 @@ +{"entryPoints":["./src/index.ts"],"excludePrivate":true,"hideGenerator":true,"out":"docs","plugin":["typedoc-plugin-missing-exports"],"tsconfig":"./tsconfig.build.json"} diff --git a/teams.json b/teams.json index b9ec8b9f00e..9ca41147efe 100644 --- a/teams.json +++ b/teams.json @@ -35,7 +35,7 @@ "metamask/multichain-api-middleware": "team-wallet-integrations", "metamask/selected-network-controller": "team-wallet-integrations", "metamask/eip-5792-middleware": "team-wallet-integrations", - "metamask/application-state-controller": "team-core-platform", + "metamask/client-state-controller": "team-core-platform", "metamask/base-controller": "team-core-platform", "metamask/build-utils": "team-core-platform", "metamask/composable-controller": "team-core-platform", diff --git a/yarn.lock b/yarn.lock index fc0610bf294..bd01c73b05d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2485,9 +2485,9 @@ __metadata: languageName: unknown linkType: soft -"@metamask/application-state-controller@workspace:packages/application-state-controller": +"@metamask/client-state-controller@workspace:packages/client-state-controller": version: 0.0.0-use.local - resolution: "@metamask/application-state-controller@workspace:packages/application-state-controller" + resolution: "@metamask/client-state-controller@workspace:packages/client-state-controller" dependencies: "@metamask/auto-changelog": "npm:^3.4.4" "@metamask/base-controller": "npm:^9.0.0" From d48d32afa8bb740d3a2f33d7f892956b70972c2e Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Thu, 5 Feb 2026 12:28:00 +0100 Subject: [PATCH 4/7] fix: clean --- README.md | 22 +- packages/client-state-controller/CHANGELOG.md | 4 +- packages/client-state-controller/README.md | 156 ++++----- packages/client-state-controller/package.json | 73 +++- .../src/ClientStateController.test.ts | 326 +++++++++--------- .../src/ClientStateController.ts | 37 +- packages/client-state-controller/src/index.ts | 3 +- .../client-state-controller/src/selectors.ts | 18 + .../tsconfig.build.json | 14 +- .../client-state-controller/tsconfig.json | 6 +- packages/client-state-controller/typedoc.json | 9 +- tsconfig.build.json | 3 + tsconfig.json | 3 + yarn.lock | 36 +- 14 files changed, 412 insertions(+), 298 deletions(-) create mode 100644 packages/client-state-controller/src/selectors.ts diff --git a/README.md b/README.md index 1efed86de9d..4aae149b6e5 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/build-utils`](packages/build-utils) - [`@metamask/chain-agnostic-permission`](packages/chain-agnostic-permission) - [`@metamask/claims-controller`](packages/claims-controller) +- [`@metamask/client-state-controller`](packages/client-state-controller) - [`@metamask/composable-controller`](packages/composable-controller) - [`@metamask/connectivity-controller`](packages/connectivity-controller) - [`@metamask/controller-utils`](packages/controller-utils) @@ -115,6 +116,7 @@ linkStyle default opacity:0.5 build_utils(["@metamask/build-utils"]); chain_agnostic_permission(["@metamask/chain-agnostic-permission"]); claims_controller(["@metamask/claims-controller"]); + client_state_controller(["@metamask/client-state-controller"]); composable_controller(["@metamask/composable-controller"]); connectivity_controller(["@metamask/connectivity-controller"]); controller_utils(["@metamask/controller-utils"]); @@ -181,16 +183,29 @@ linkStyle default opacity:0.5 address_book_controller --> base_controller; address_book_controller --> controller_utils; address_book_controller --> messenger; + ai_controllers --> base_controller; + ai_controllers --> messenger; analytics_controller --> base_controller; analytics_controller --> messenger; + analytics_data_regulation_controller --> base_controller; + analytics_data_regulation_controller --> controller_utils; + analytics_data_regulation_controller --> messenger; announcement_controller --> base_controller; announcement_controller --> messenger; app_metadata_controller --> base_controller; app_metadata_controller --> messenger; approval_controller --> base_controller; approval_controller --> messenger; + assets_controller --> account_tree_controller; assets_controller --> base_controller; + assets_controller --> controller_utils; + assets_controller --> core_backend; + assets_controller --> keyring_controller; assets_controller --> messenger; + assets_controller --> network_controller; + assets_controller --> network_enablement_controller; + assets_controller --> permission_controller; + assets_controller --> polling_controller; assets_controllers --> account_tree_controller; assets_controllers --> accounts_controller; assets_controllers --> approval_controller; @@ -201,11 +216,13 @@ linkStyle default opacity:0.5 assets_controllers --> messenger; assets_controllers --> multichain_account_service; assets_controllers --> network_controller; + assets_controllers --> network_enablement_controller; assets_controllers --> permission_controller; assets_controllers --> phishing_controller; assets_controllers --> polling_controller; assets_controllers --> preferences_controller; assets_controllers --> profile_sync_controller; + assets_controllers --> storage_service; assets_controllers --> transaction_controller; base_controller --> messenger; base_controller --> json_rpc_engine; @@ -236,6 +253,8 @@ linkStyle default opacity:0.5 claims_controller --> messenger; claims_controller --> keyring_controller; claims_controller --> profile_sync_controller; + client_state_controller --> base_controller; + client_state_controller --> messenger; composable_controller --> base_controller; composable_controller --> messenger; composable_controller --> json_rpc_engine; @@ -412,12 +431,11 @@ linkStyle default opacity:0.5 subscription_controller --> polling_controller; subscription_controller --> profile_sync_controller; subscription_controller --> transaction_controller; - token_search_discovery_controller --> base_controller; - token_search_discovery_controller --> messenger; transaction_controller --> accounts_controller; transaction_controller --> approval_controller; transaction_controller --> base_controller; transaction_controller --> controller_utils; + transaction_controller --> core_backend; transaction_controller --> gas_fee_controller; transaction_controller --> messenger; transaction_controller --> network_controller; diff --git a/packages/client-state-controller/CHANGELOG.md b/packages/client-state-controller/CHANGELOG.md index fef60c15109..5e2a817b9bc 100644 --- a/packages/client-state-controller/CHANGELOG.md +++ b/packages/client-state-controller/CHANGELOG.md @@ -9,12 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- Initial release of `@metamask/client-state-controller` (renamed from `@metamask/application-state-controller`) +- Initial release of `@metamask/client-state-controller` ([#7808](https://github.com/MetaMask/core/pull/7808)) - `ClientStateController` for managing client (UI) open/closed state - `ClientStateController:setClientOpen` messenger action for platform code to call - `ClientStateController:stateChange` event for controllers to subscribe to lifecycle changes - `isClientOpen` state property (not persisted - always starts as `false`) - - `selectIsClientOpen` selector for derived state access + - `clientStateControllerSelectors.selectIsClientOpen` selector for derived state access - Full TypeScript support with exported types [Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/client-state-controller/README.md b/packages/client-state-controller/README.md index a4d42816c17..82eab4c2dd0 100644 --- a/packages/client-state-controller/README.md +++ b/packages/client-state-controller/README.md @@ -1,52 +1,29 @@ # `@metamask/client-state-controller` -Manages client lifecycle state (client open/closed) for cross-platform MetaMask applications. +Tracks and manages the lifecycle state of MetaMask as a client. ## Overview 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. -### The Problem It Solves +**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. -Previously, lifecycle management was scattered across platform code: +## Important: Usage guidelines and warnings -```typescript -// In MetamaskController (extension) -set isClientOpen(open) { - this._isClientOpen = open; - // Manually call each controller/service - this.controllerMessenger.call('SnapController:setClientActive', open); - if (open) { - this.controllerMessenger.call('BackendWebSocketService:connect'); - } else { - this.controllerMessenger.call('BackendWebSocketService:disconnect'); - } -} -``` +**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: -### The Solution +- Unnecessary network traffic and battery use +- **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" +- Poor performance and scalability as more features are added -With `ClientStateController`, controllers manage themselves: +**Use this controller responsibly:** -```typescript -// Platform code calls the controller via messenger -set isClientOpen(open) { - this.controllerMessenger.call('ClientStateController:setClientOpen', open); -} - -// Controllers subscribe to stateChange and manage themselves -class MyController extends BaseController { - constructor({ messenger }) { - messenger.subscribe('ClientStateController:stateChange', (newState) => { - if (newState.isClientOpen) { - this.start(); - } else { - this.stop(); - } - }); - } -} -``` +- Start only the subscriptions, polling, or requests that are **needed for the current screen or flow** +- Do **not** start network-dependent or heavy behavior solely because `ClientStateController:stateChange` reported `isClientOpen: true` +- Consider **deferring** non-critical updates until the user has completed onboarding or reached a screen that needs that data +- 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 +- **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). +- **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). ## Installation @@ -91,10 +68,7 @@ const clientStateController = new ClientStateController({ class MetamaskController { // Platform calls this when UI opens/closes set isClientOpen(open) { - this.controllerMessenger.call( - 'ClientStateController:setClientOpen', - open, - ); + this.controllerMessenger.call('ClientStateController:setClientOpen', open); } } ``` @@ -105,78 +79,100 @@ class MetamaskController { import { AppState } from 'react-native'; // In Engine initialization -AppState.addEventListener('change', (nextAppState) => { - if (nextAppState !== 'active' && nextAppState !== 'background') { - return; - } +AppState.addEventListener('change', (state) => { controllerMessenger.call( 'ClientStateController:setClientOpen', - nextAppState === 'active', + state === 'active', ); }); ``` ### Consumer Controller +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. + +**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. + ```typescript +import { clientStateControllerSelectors } from '@metamask/client-state-controller'; + class TokenBalancesController extends BaseController { constructor({ messenger }) { super({ messenger, ... }); - // Subscribe to lifecycle state changes + // Subscribe with a selector so the handler receives isClientOpen (boolean). + // Prefer pause/resume so polling can be resumed without full re-initialization. this.messenger.subscribe( 'ClientStateController:stateChange', - (newState) => { - if (newState.isClientOpen) { - this.startPolling(); + (isClientOpen) => { + if (isClientOpen) { + this.resumePolling(); } else { - this.stopPolling(); + this.pausePolling(); } }, + (state) => clientStateControllerSelectors.selectIsClientOpen(state), ); } - startPolling() { - // Start polling when client opens + resumePolling() { + // Start polling if previously paused, otherwise do nothing } - stopPolling() { - // Stop polling when client closes + pausePolling() { + // Mark that polling is paused so resumePolling can restart it later, + // and ensure that polling is stopped } } ``` -### WebSocket Controller Example +Note: `stateChange` emits `[state, patches]`; the selector receives the full payload and returns the value passed to the handler (here, `isClientOpen`). + +### Using with other lifecycle state (e.g. Keyring unlock/lock) + +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: + +- **KeyringController:unlock** / **KeyringController:lock** — whether the wallet is unlocked (user has completed onboarding / is logged in) +- Any other controller that expresses "ready for background work" or "user session active" + +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. ```typescript -class WebSocketController extends BaseController { - #socket: WebSocket | null = null; +import { clientStateControllerSelectors } from '@metamask/client-state-controller'; + +class SomeDataController extends BaseController { + #clientOpen = false; + #keyringUnlocked = false; constructor({ messenger }) { super({ messenger, ... }); messenger.subscribe( 'ClientStateController:stateChange', - (newState) => { - if (newState.isClientOpen) { - this.connect(); - } else { - this.disconnect(); - } + (isClientOpen) => { + this.#clientOpen = isClientOpen; + this.updateActive(); }, + (state) => clientStateControllerSelectors.selectIsClientOpen(state), ); - } - connect() { - if (!this.#socket) { - this.#socket = new WebSocket('wss://example.com'); - } + messenger.subscribe('KeyringController:unlock', () => { + this.#keyringUnlocked = true; + this.updateActive(); + }); + + messenger.subscribe('KeyringController:lock', () => { + this.#keyringUnlocked = false; + this.updateActive(); + }); } - disconnect() { - if (this.#socket) { - this.#socket.close(); - this.#socket = null; + updateActive() { + const shouldRun = this.#clientOpen && this.#keyringUnlocked; + if (shouldRun) { + this.resume(); + } else { + this.pause(); } } } @@ -194,24 +190,24 @@ Note: State is not persisted. It always starts as `false`. ### Actions -| Action | Parameters | Description | -| ----------------------------------------- | --------------- | -------------------------------- | -| `ClientStateController:getState` | none | Returns current state. | +| Action | Parameters | Description | +| ------------------------------------- | --------------- | -------------------------------- | +| `ClientStateController:getState` | none | Returns current state. | | `ClientStateController:setClientOpen` | `open: boolean` | Sets whether the client is open. | ### Events -| Event | Payload | Description | -| -------------------------------------- | ------------------ | ---------------------------- | +| Event | Payload | Description | +| ----------------------------------- | ------------------ | ---------------------------- | | `ClientStateController:stateChange` | `[state, patches]` | Standard state change event. | ### Selectors ```typescript -import { selectIsClientOpen } from '@metamask/client-state-controller'; +import { clientStateControllerSelectors } from '@metamask/client-state-controller'; const state = messenger.call('ClientStateController:getState'); -const isOpen = selectIsClientOpen(state); +const isOpen = clientStateControllerSelectors.selectIsClientOpen(state); ``` ## Contributing diff --git a/packages/client-state-controller/package.json b/packages/client-state-controller/package.json index 608e210dffe..a7cc3da6268 100644 --- a/packages/client-state-controller/package.json +++ b/packages/client-state-controller/package.json @@ -1 +1,72 @@ -{"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/"}} +{ + "name": "@metamask/client-state-controller", + "version": "0.0.0", + "description": "Tracks and manages the lifecycle state of MetaMask as a client.", + "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/" + } +} diff --git a/packages/client-state-controller/src/ClientStateController.test.ts b/packages/client-state-controller/src/ClientStateController.test.ts index d836c50d1a5..7b980b53ca3 100644 --- a/packages/client-state-controller/src/ClientStateController.test.ts +++ b/packages/client-state-controller/src/ClientStateController.test.ts @@ -4,206 +4,217 @@ import type { ClientStateControllerActions, ClientStateControllerEvents, ClientStateControllerMessenger, - ClientStateControllerState, } from './ClientStateController'; import { ClientStateController, controllerName, getDefaultClientStateControllerState, - selectIsClientOpen, } from './ClientStateController'; +import { clientStateControllerSelectors } from './selectors'; describe('ClientStateController', () => { + type RootMessenger = Messenger< + 'Root', + ClientStateControllerActions, + ClientStateControllerEvents + >; + /** - * Creates a messenger for the ClientStateController. + * Constructs the root messenger. * - * @returns A messenger for the controller. + * @returns The root messenger. */ - function createMessenger(): ClientStateControllerMessenger { - const rootMessenger = new Messenger< + function getRootMessenger(): RootMessenger { + return new Messenger< 'Root', ClientStateControllerActions, ClientStateControllerEvents >({ namespace: 'Root' }); + } + /** + * Constructs the messenger for the ClientStateController. + * + * @param rootMessenger - The root messenger. + * @returns The controller-specific messenger. + */ + function getMessenger( + rootMessenger: RootMessenger, + ): ClientStateControllerMessenger { return new Messenger< typeof controllerName, ClientStateControllerActions, ClientStateControllerEvents, - typeof rootMessenger + RootMessenger >({ namespace: controllerName, parent: rootMessenger, }); } + type WithControllerCallback = (payload: { + controller: ClientStateController; + rootMessenger: RootMessenger; + messenger: ClientStateControllerMessenger; + }) => Promise | ReturnValue; + + type WithControllerOptions = { + options: Partial[0]>; + }; + /** - * Creates a ClientStateController. + * Wraps tests for the controller by creating the controller and messengers, + * then calling the test function with them. * - * @param options - Options for creating the controller. - * @param options.state - Initial state to set on the controller. - * @returns The controller and messenger. + * @param args - Either a callback, or an options bag + a callback. The + * options bag contains arguments for the controller constructor. The + * callback is called with the new controller, root messenger, and + * controller messenger. + * @returns The return value of the callback. */ - function createController(options?: { - state?: Partial; - }): { - controller: ClientStateController; - messenger: ClientStateControllerMessenger; - } { - const messenger = createMessenger(); + async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] + ): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); const controller = new ClientStateController({ messenger, - state: options?.state, + ...options, }); - return { controller, messenger }; + return await testFunction({ controller, rootMessenger, messenger }); } describe('constructor', () => { - it('initializes with default state (client closed)', () => { - const { controller } = createController(); - - expect(controller.state.isClientOpen).toBe(false); - expect(controller.isClientOpen).toBe(false); - }); - - it('allows initializing with partial state', () => { - const { controller } = createController({ - state: { isClientOpen: true }, + it('initializes with default state (client closed)', async () => { + await withController(({ controller }) => { + expect(controller.state.isClientOpen).toBe(false); + expect( + clientStateControllerSelectors.selectIsClientOpen(controller.state), + ).toBe(false); }); + }); - expect(controller.state.isClientOpen).toBe(true); - expect(controller.isClientOpen).toBe(true); + it('allows initializing with partial state', async () => { + await withController( + { options: { state: { isClientOpen: true } } }, + ({ controller }) => { + expect(controller.state.isClientOpen).toBe(true); + expect( + clientStateControllerSelectors.selectIsClientOpen(controller.state), + ).toBe(true); + }, + ); }); - it('merges partial state with defaults', () => { - const { controller } = createController({ - state: {}, + it('merges partial state with defaults', async () => { + await withController({ options: { state: {} } }, ({ controller }) => { + expect(controller.state.isClientOpen).toBe(false); }); - - expect(controller.state.isClientOpen).toBe(false); }); }); describe('setClientOpen', () => { - it('updates state when client opens', () => { - const { controller } = createController(); - - controller.setClientOpen(true); - - expect(controller.state.isClientOpen).toBe(true); - expect(controller.isClientOpen).toBe(true); - }); - - it('updates state when client closes', () => { - const { controller } = createController(); - controller.setClientOpen(true); - - controller.setClientOpen(false); + it('updates isClientOpen in state to the given value', async () => { + await withController(({ controller }) => { + controller.setClientOpen(true); - expect(controller.state.isClientOpen).toBe(false); - expect(controller.isClientOpen).toBe(false); - }); - - it('does not update state when setting the same value', () => { - const { controller, messenger } = createController(); - controller.setClientOpen(true); - const listener = jest.fn(); - messenger.subscribe(`${controllerName}:stateChange`, listener); + expect(controller.state.isClientOpen).toBe(true); + expect( + clientStateControllerSelectors.selectIsClientOpen(controller.state), + ).toBe(true); - controller.setClientOpen(true); - - expect(listener).not.toHaveBeenCalled(); - }); + controller.setClientOpen(false); - it('publishes stateChange event when client opens', () => { - const { controller, messenger } = createController(); - const listener = jest.fn(); - - messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientOpen(true); - - expect(listener).toHaveBeenCalledTimes(1); - const [newState] = listener.mock.calls[0]; - expect(newState.isClientOpen).toBe(true); + expect(controller.state.isClientOpen).toBe(false); + expect( + clientStateControllerSelectors.selectIsClientOpen(controller.state), + ).toBe(false); + }); }); - it('publishes stateChange event when client closes', () => { - const { controller, messenger } = createController(); - controller.setClientOpen(true); - const listener = jest.fn(); + it('publishes stateChange event when isClientOpen changes to true', async () => { + await withController(({ controller, messenger }) => { + const listener = jest.fn(); + messenger.subscribe(`${controllerName}:stateChange`, listener); + controller.setClientOpen(true); - messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientOpen(false); - - expect(listener).toHaveBeenCalledTimes(1); - const [newState] = listener.mock.calls[0]; - expect(newState.isClientOpen).toBe(false); + expect(listener).toHaveBeenCalledTimes(1); + const [newState] = listener.mock.calls[0]; + expect(newState.isClientOpen).toBe(true); + }); }); - it('does not publish stateChange when state does not change', () => { - const { controller, messenger } = createController(); - controller.setClientOpen(true); - const listener = jest.fn(); + it('publishes stateChange event when isClientOpen changes to false', async () => { + await withController(({ controller, messenger }) => { + controller.setClientOpen(true); + const listener = jest.fn(); + messenger.subscribe(`${controllerName}:stateChange`, listener); + controller.setClientOpen(false); - messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientOpen(true); - - expect(listener).not.toHaveBeenCalled(); + expect(listener).toHaveBeenCalledTimes(1); + const [newState] = listener.mock.calls[0]; + expect(newState.isClientOpen).toBe(false); + }); }); }); - describe('isClientOpen getter', () => { - it('returns true when client is open', () => { - const { controller } = createController(); - controller.setClientOpen(true); - - expect(controller.isClientOpen).toBe(true); + describe('selectIsClientOpen selector', () => { + it('returns true when client is open', async () => { + await withController(({ controller }) => { + controller.setClientOpen(true); + expect( + clientStateControllerSelectors.selectIsClientOpen(controller.state), + ).toBe(true); + }); }); - it('returns false when client is closed', () => { - const { controller } = createController(); - - expect(controller.isClientOpen).toBe(false); + it('returns false when client is closed', async () => { + await withController(({ controller }) => { + expect( + clientStateControllerSelectors.selectIsClientOpen(controller.state), + ).toBe(false); + }); }); }); describe('messenger actions', () => { - it('allows getting state via messenger action', () => { - const { controller, messenger } = createController(); - controller.setClientOpen(true); - - const state = messenger.call(`${controllerName}:getState`); - - expect(state.isClientOpen).toBe(true); + it('allows getting state via messenger action', async () => { + await withController(({ controller, messenger }) => { + controller.setClientOpen(true); + const state = messenger.call(`${controllerName}:getState`); + expect(state.isClientOpen).toBe(true); + }); }); - it('allows setting client open via messenger action', () => { - const { controller, messenger } = createController(); - - messenger.call(`${controllerName}:setClientOpen`, true); - - expect(controller.state.isClientOpen).toBe(true); + it('allows setting client open via messenger action', async () => { + await withController(({ controller, messenger }) => { + messenger.call(`${controllerName}:setClientOpen`, true); + expect(controller.state.isClientOpen).toBe(true); + }); }); - it('allows setting client closed via messenger action', () => { - const { controller, messenger } = createController(); - controller.setClientOpen(true); - - messenger.call(`${controllerName}:setClientOpen`, false); - - expect(controller.state.isClientOpen).toBe(false); + it('allows setting client closed via messenger action', async () => { + await withController(({ controller, messenger }) => { + controller.setClientOpen(true); + messenger.call(`${controllerName}:setClientOpen`, false); + expect(controller.state.isClientOpen).toBe(false); + }); }); - it('publishes stateChange when setting via messenger action', () => { - const { messenger } = createController(); - const listener = jest.fn(); - - messenger.subscribe(`${controllerName}:stateChange`, listener); - messenger.call(`${controllerName}:setClientOpen`, true); + it('publishes stateChange when setting via messenger action', async () => { + await withController(({ messenger }) => { + const listener = jest.fn(); + messenger.subscribe(`${controllerName}:stateChange`, listener); + messenger.call(`${controllerName}:setClientOpen`, true); - expect(listener).toHaveBeenCalledTimes(1); - const [newState] = listener.mock.calls[0]; - expect(newState.isClientOpen).toBe(true); + expect(listener).toHaveBeenCalledTimes(1); + const [newState] = listener.mock.calls[0]; + expect(newState.isClientOpen).toBe(true); + }); }); }); @@ -218,42 +229,39 @@ describe('ClientStateController', () => { describe('selectors', () => { describe('selectIsClientOpen', () => { it('returns true when client is open', () => { - expect(selectIsClientOpen({ isClientOpen: true })).toBe(true); + expect( + clientStateControllerSelectors.selectIsClientOpen({ + isClientOpen: true, + }), + ).toBe(true); }); it('returns false when client is closed', () => { - expect(selectIsClientOpen({ isClientOpen: false })).toBe(false); + expect( + clientStateControllerSelectors.selectIsClientOpen({ + isClientOpen: false, + }), + ).toBe(false); }); }); }); describe('lifecycle scenarios', () => { - it('handles multiple open/close cycles', () => { - const { controller, messenger } = createController(); - const listener = jest.fn(); - - messenger.subscribe(`${controllerName}:stateChange`, listener); - - controller.setClientOpen(true); - controller.setClientOpen(false); - controller.setClientOpen(true); - controller.setClientOpen(false); - - expect(listener).toHaveBeenCalledTimes(4); - expect(controller.isClientOpen).toBe(false); - }); - - it('ignores repeated calls with same value', () => { - const { controller, messenger } = createController(); - const listener = jest.fn(); - - messenger.subscribe(`${controllerName}:stateChange`, listener); - - controller.setClientOpen(true); - controller.setClientOpen(true); - controller.setClientOpen(true); - - expect(listener).toHaveBeenCalledTimes(1); + it('handles multiple open/close cycles', async () => { + await withController(({ controller, messenger }) => { + const listener = jest.fn(); + messenger.subscribe(`${controllerName}:stateChange`, listener); + + controller.setClientOpen(true); + controller.setClientOpen(false); + controller.setClientOpen(true); + controller.setClientOpen(false); + + expect(listener).toHaveBeenCalledTimes(4); + expect( + clientStateControllerSelectors.selectIsClientOpen(controller.state), + ).toBe(false); + }); }); }); }); diff --git a/packages/client-state-controller/src/ClientStateController.ts b/packages/client-state-controller/src/ClientStateController.ts index 8cecd254bd8..5b53d681c9d 100644 --- a/packages/client-state-controller/src/ClientStateController.ts +++ b/packages/client-state-controller/src/ClientStateController.ts @@ -81,17 +81,15 @@ type AllowedActions = never; /** * Published when the state of {@link ClientStateController} changes. */ -export type ClientStateControllerStateChangeEvent = - ControllerStateChangeEvent< - typeof controllerName, - ClientStateControllerState - >; +export type ClientStateControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + ClientStateControllerState +>; /** * Events that {@link ClientStateController} exposes. */ -export type ClientStateControllerEvents = - ClientStateControllerStateChangeEvent; +export type ClientStateControllerEvents = ClientStateControllerStateChangeEvent; /** * Events from other messengers that {@link ClientStateController} subscribes to. @@ -188,7 +186,7 @@ export class ClientStateController extends BaseController< // Register the setClientOpen action this.messenger.registerActionHandler( - `${controllerName}:setClientOpen`, + `ClientStateController:setClientOpen`, this.setClientOpen.bind(this), ); } @@ -209,27 +207,4 @@ export class ClientStateController extends BaseController< }); } } - - /** - * Returns whether the client is currently open. - * - * @returns True if the client is open. - */ - get isClientOpen(): boolean { - return this.state.isClientOpen; - } -} - -// === SELECTORS === - -/** - * Selects whether the client is currently open. - * - * @param state - The ClientStateController state. - * @returns True if the client is open. - */ -export function selectIsClientOpen( - state: ClientStateControllerState, -): boolean { - return state.isClientOpen; } diff --git a/packages/client-state-controller/src/index.ts b/packages/client-state-controller/src/index.ts index af583c66efe..0de3bfe90ae 100644 --- a/packages/client-state-controller/src/index.ts +++ b/packages/client-state-controller/src/index.ts @@ -1,9 +1,8 @@ export { ClientStateController, - controllerName, getDefaultClientStateControllerState, - selectIsClientOpen, } from './ClientStateController'; +export { clientStateControllerSelectors } from './selectors'; export type { ClientStateControllerState, diff --git a/packages/client-state-controller/src/selectors.ts b/packages/client-state-controller/src/selectors.ts new file mode 100644 index 00000000000..46c150854f4 --- /dev/null +++ b/packages/client-state-controller/src/selectors.ts @@ -0,0 +1,18 @@ +import type { ClientStateControllerState } from './ClientStateController'; + +/** + * Selects whether the client is currently open. + * + * @param state - The ClientStateController state. + * @returns True if the client is open. + */ +const selectIsClientOpen = (state: ClientStateControllerState): boolean => + state.isClientOpen; + +/** + * Selectors for the ClientStateController state. + * These can be used with Redux or directly with controller state. + */ +export const clientStateControllerSelectors = { + selectIsClientOpen, +}; diff --git a/packages/client-state-controller/tsconfig.build.json b/packages/client-state-controller/tsconfig.build.json index 86e6fcd1303..931c4d6594b 100644 --- a/packages/client-state-controller/tsconfig.build.json +++ b/packages/client-state-controller/tsconfig.build.json @@ -1 +1,13 @@ -{"extends":"../../tsconfig.packages.build.json","compilerOptions":{"baseUrl":"./","outDir":"./dist","rootDir":"./src"},"references":[{"path":"../base-controller/tsconfig.build.json"},{"path":"../messenger/tsconfig.build.json"}],"include":["../../types","./src"]} +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../messenger/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/client-state-controller/tsconfig.json b/packages/client-state-controller/tsconfig.json index db784ac0317..3184a4bfde9 100644 --- a/packages/client-state-controller/tsconfig.json +++ b/packages/client-state-controller/tsconfig.json @@ -1 +1,5 @@ -{"extends":"../../tsconfig.packages.json","compilerOptions":{"baseUrl":"."},"include":["src"]} +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { "baseUrl": "." }, + "include": ["src"] +} diff --git a/packages/client-state-controller/typedoc.json b/packages/client-state-controller/typedoc.json index d7395bbc67e..d02905868c6 100644 --- a/packages/client-state-controller/typedoc.json +++ b/packages/client-state-controller/typedoc.json @@ -1 +1,8 @@ -{"entryPoints":["./src/index.ts"],"excludePrivate":true,"hideGenerator":true,"out":"docs","plugin":["typedoc-plugin-missing-exports"],"tsconfig":"./tsconfig.build.json"} +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "plugin": ["typedoc-plugin-missing-exports"], + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 8fa8236e1b1..1fc79029609 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -58,6 +58,9 @@ { "path": "./packages/claims-controller/tsconfig.build.json" }, + { + "path": "./packages/client-state-controller/tsconfig.build.json" + }, { "path": "./packages/composable-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 43acf48fbd2..efae5f14f7b 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -59,6 +59,9 @@ { "path": "./packages/claims-controller" }, + { + "path": "./packages/client-state-controller" + }, { "path": "./packages/composable-controller" }, diff --git a/yarn.lock b/yarn.lock index bd01c73b05d..0754718b58f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2485,24 +2485,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/client-state-controller@workspace:packages/client-state-controller": - version: 0.0.0-use.local - resolution: "@metamask/client-state-controller@workspace:packages/client-state-controller" - dependencies: - "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" - deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" - typedoc-plugin-missing-exports: "npm:^2.0.0" - typescript: "npm:~5.3.3" - languageName: unknown - linkType: soft - "@metamask/approval-controller@npm:^8.0.0, @metamask/approval-controller@workspace:packages/approval-controller": version: 0.0.0-use.local resolution: "@metamask/approval-controller@workspace:packages/approval-controller" @@ -2877,6 +2859,24 @@ __metadata: languageName: unknown linkType: soft +"@metamask/client-state-controller@workspace:packages/client-state-controller": + version: 0.0.0-use.local + resolution: "@metamask/client-state-controller@workspace:packages/client-state-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.5.2" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.5" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/composable-controller@workspace:packages/composable-controller": version: 0.0.0-use.local resolution: "@metamask/composable-controller@workspace:packages/composable-controller" From 75a3d0ee0d0b1c205927043f3a16652922901574 Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Thu, 5 Feb 2026 22:22:58 +0100 Subject: [PATCH 5/7] uiStateController --- .github/CODEOWNERS | 2 +- README.md | 8 +- packages/client-state-controller/CHANGELOG.md | 20 -- packages/client-state-controller/README.md | 215 -------------- .../src/ClientStateController.test.ts | 267 ------------------ .../src/ClientStateController.ts | 210 -------------- packages/client-state-controller/src/index.ts | 16 -- .../client-state-controller/src/selectors.ts | 18 -- packages/ui-state-controller/CHANGELOG.md | 20 ++ .../LICENSE | 0 packages/ui-state-controller/README.md | 151 ++++++++++ .../jest.config.js | 0 .../package.json | 10 +- .../UiStateController-method-action-types.ts | 25 ++ .../src/UiStateController.test.ts | 189 +++++++++++++ .../src/UiStateController.ts | 214 ++++++++++++++ packages/ui-state-controller/src/index.ts | 19 ++ packages/ui-state-controller/src/selectors.ts | 18 ++ .../tsconfig.build.json | 0 .../tsconfig.json | 0 .../typedoc.json | 0 teams.json | 2 +- tsconfig.build.json | 2 +- tsconfig.json | 2 +- yarn.lock | 36 +-- 25 files changed, 667 insertions(+), 777 deletions(-) delete mode 100644 packages/client-state-controller/CHANGELOG.md delete mode 100644 packages/client-state-controller/README.md delete mode 100644 packages/client-state-controller/src/ClientStateController.test.ts delete mode 100644 packages/client-state-controller/src/ClientStateController.ts delete mode 100644 packages/client-state-controller/src/index.ts delete mode 100644 packages/client-state-controller/src/selectors.ts create mode 100644 packages/ui-state-controller/CHANGELOG.md rename packages/{client-state-controller => ui-state-controller}/LICENSE (100%) create mode 100644 packages/ui-state-controller/README.md rename packages/{client-state-controller => ui-state-controller}/jest.config.js (100%) rename packages/{client-state-controller => ui-state-controller}/package.json (92%) create mode 100644 packages/ui-state-controller/src/UiStateController-method-action-types.ts create mode 100644 packages/ui-state-controller/src/UiStateController.test.ts create mode 100644 packages/ui-state-controller/src/UiStateController.ts create mode 100644 packages/ui-state-controller/src/index.ts create mode 100644 packages/ui-state-controller/src/selectors.ts rename packages/{client-state-controller => ui-state-controller}/tsconfig.build.json (100%) rename packages/{client-state-controller => ui-state-controller}/tsconfig.json (100%) rename packages/{client-state-controller => ui-state-controller}/typedoc.json (100%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 44184932578..f52bf2f8b65 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,7 +71,7 @@ /packages/eip-5792-middleware @MetaMask/wallet-integrations ## Core Platform Team -/packages/client-state-controller @MetaMask/core-platform +/packages/ui-state-controller @MetaMask/core-platform /packages/base-controller @MetaMask/core-platform /packages/build-utils @MetaMask/core-platform /packages/composable-controller @MetaMask/core-platform diff --git a/README.md b/README.md index 4aae149b6e5..f091822eac7 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,6 @@ Each package in this repository has its own README where you can find installati - [`@metamask/build-utils`](packages/build-utils) - [`@metamask/chain-agnostic-permission`](packages/chain-agnostic-permission) - [`@metamask/claims-controller`](packages/claims-controller) -- [`@metamask/client-state-controller`](packages/client-state-controller) - [`@metamask/composable-controller`](packages/composable-controller) - [`@metamask/connectivity-controller`](packages/connectivity-controller) - [`@metamask/controller-utils`](packages/controller-utils) @@ -89,6 +88,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/subscription-controller`](packages/subscription-controller) - [`@metamask/transaction-controller`](packages/transaction-controller) - [`@metamask/transaction-pay-controller`](packages/transaction-pay-controller) +- [`@metamask/ui-state-controller`](packages/ui-state-controller) - [`@metamask/user-operation-controller`](packages/user-operation-controller) @@ -116,7 +116,6 @@ linkStyle default opacity:0.5 build_utils(["@metamask/build-utils"]); chain_agnostic_permission(["@metamask/chain-agnostic-permission"]); claims_controller(["@metamask/claims-controller"]); - client_state_controller(["@metamask/client-state-controller"]); composable_controller(["@metamask/composable-controller"]); connectivity_controller(["@metamask/connectivity-controller"]); controller_utils(["@metamask/controller-utils"]); @@ -168,6 +167,7 @@ linkStyle default opacity:0.5 subscription_controller(["@metamask/subscription-controller"]); transaction_controller(["@metamask/transaction-controller"]); transaction_pay_controller(["@metamask/transaction-pay-controller"]); + ui_state_controller(["@metamask/ui-state-controller"]); user_operation_controller(["@metamask/user-operation-controller"]); account_tree_controller --> accounts_controller; account_tree_controller --> base_controller; @@ -253,8 +253,6 @@ linkStyle default opacity:0.5 claims_controller --> messenger; claims_controller --> keyring_controller; claims_controller --> profile_sync_controller; - client_state_controller --> base_controller; - client_state_controller --> messenger; composable_controller --> base_controller; composable_controller --> messenger; composable_controller --> json_rpc_engine; @@ -452,6 +450,8 @@ linkStyle default opacity:0.5 transaction_pay_controller --> network_controller; transaction_pay_controller --> remote_feature_flag_controller; transaction_pay_controller --> transaction_controller; + ui_state_controller --> base_controller; + ui_state_controller --> messenger; user_operation_controller --> approval_controller; user_operation_controller --> base_controller; user_operation_controller --> controller_utils; diff --git a/packages/client-state-controller/CHANGELOG.md b/packages/client-state-controller/CHANGELOG.md deleted file mode 100644 index 5e2a817b9bc..00000000000 --- a/packages/client-state-controller/CHANGELOG.md +++ /dev/null @@ -1,20 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added - -- Initial release of `@metamask/client-state-controller` ([#7808](https://github.com/MetaMask/core/pull/7808)) - - `ClientStateController` for managing client (UI) open/closed state - - `ClientStateController:setClientOpen` messenger action for platform code to call - - `ClientStateController:stateChange` event for controllers to subscribe to lifecycle changes - - `isClientOpen` state property (not persisted - always starts as `false`) - - `clientStateControllerSelectors.selectIsClientOpen` selector for derived state access - - Full TypeScript support with exported types - -[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/client-state-controller/README.md b/packages/client-state-controller/README.md deleted file mode 100644 index 82eab4c2dd0..00000000000 --- a/packages/client-state-controller/README.md +++ /dev/null @@ -1,215 +0,0 @@ -# `@metamask/client-state-controller` - -Tracks and manages the lifecycle state of MetaMask as a client. - -## Overview - -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. - -**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. - -## Important: Usage guidelines and warnings - -**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: - -- Unnecessary network traffic and battery use -- **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" -- Poor performance and scalability as more features are added - -**Use this controller responsibly:** - -- Start only the subscriptions, polling, or requests that are **needed for the current screen or flow** -- Do **not** start network-dependent or heavy behavior solely because `ClientStateController:stateChange` reported `isClientOpen: true` -- Consider **deferring** non-critical updates until the user has completed onboarding or reached a screen that needs that data -- 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 -- **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). -- **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). - -## Installation - -`yarn add @metamask/client-state-controller` - -or - -`npm install @metamask/client-state-controller` - -## Usage - -### Basic Setup - -```typescript -import { Messenger } from '@metamask/messenger'; -import { - ClientStateController, - ClientStateControllerActions, - ClientStateControllerEvents, -} from '@metamask/client-state-controller'; - -const rootMessenger = new Messenger< - 'Root', - ClientStateControllerActions, - ClientStateControllerEvents ->({ namespace: 'Root' }); - -const controllerMessenger = new Messenger({ - namespace: 'ClientStateController', - parent: rootMessenger, -}); - -const clientStateController = new ClientStateController({ - messenger: controllerMessenger, -}); -``` - -### Platform Integration (Extension) - -```typescript -// In MetamaskController -class MetamaskController { - // Platform calls this when UI opens/closes - set isClientOpen(open) { - this.controllerMessenger.call('ClientStateController:setClientOpen', open); - } -} -``` - -### Platform Integration (React Native) - -```typescript -import { AppState } from 'react-native'; - -// In Engine initialization -AppState.addEventListener('change', (state) => { - controllerMessenger.call( - 'ClientStateController:setClientOpen', - state === 'active', - ); -}); -``` - -### Consumer Controller - -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. - -**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. - -```typescript -import { clientStateControllerSelectors } from '@metamask/client-state-controller'; - -class TokenBalancesController extends BaseController { - constructor({ messenger }) { - super({ messenger, ... }); - - // Subscribe with a selector so the handler receives isClientOpen (boolean). - // Prefer pause/resume so polling can be resumed without full re-initialization. - this.messenger.subscribe( - 'ClientStateController:stateChange', - (isClientOpen) => { - if (isClientOpen) { - this.resumePolling(); - } else { - this.pausePolling(); - } - }, - (state) => clientStateControllerSelectors.selectIsClientOpen(state), - ); - } - - resumePolling() { - // Start polling if previously paused, otherwise do nothing - } - - pausePolling() { - // Mark that polling is paused so resumePolling can restart it later, - // and ensure that polling is stopped - } -} -``` - -Note: `stateChange` emits `[state, patches]`; the selector receives the full payload and returns the value passed to the handler (here, `isClientOpen`). - -### Using with other lifecycle state (e.g. Keyring unlock/lock) - -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: - -- **KeyringController:unlock** / **KeyringController:lock** — whether the wallet is unlocked (user has completed onboarding / is logged in) -- Any other controller that expresses "ready for background work" or "user session active" - -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. - -```typescript -import { clientStateControllerSelectors } from '@metamask/client-state-controller'; - -class SomeDataController extends BaseController { - #clientOpen = false; - #keyringUnlocked = false; - - constructor({ messenger }) { - super({ messenger, ... }); - - messenger.subscribe( - 'ClientStateController:stateChange', - (isClientOpen) => { - this.#clientOpen = isClientOpen; - this.updateActive(); - }, - (state) => clientStateControllerSelectors.selectIsClientOpen(state), - ); - - messenger.subscribe('KeyringController:unlock', () => { - this.#keyringUnlocked = true; - this.updateActive(); - }); - - messenger.subscribe('KeyringController:lock', () => { - this.#keyringUnlocked = false; - this.updateActive(); - }); - } - - updateActive() { - const shouldRun = this.#clientOpen && this.#keyringUnlocked; - if (shouldRun) { - this.resume(); - } else { - this.pause(); - } - } -} -``` - -## API Reference - -### State - -| Property | Type | Description | -| -------------- | --------- | ------------------------------------------ | -| `isClientOpen` | `boolean` | Whether the client (UI) is currently open. | - -Note: State is not persisted. It always starts as `false`. - -### Actions - -| Action | Parameters | Description | -| ------------------------------------- | --------------- | -------------------------------- | -| `ClientStateController:getState` | none | Returns current state. | -| `ClientStateController:setClientOpen` | `open: boolean` | Sets whether the client is open. | - -### Events - -| Event | Payload | Description | -| ----------------------------------- | ------------------ | ---------------------------- | -| `ClientStateController:stateChange` | `[state, patches]` | Standard state change event. | - -### Selectors - -```typescript -import { clientStateControllerSelectors } from '@metamask/client-state-controller'; - -const state = messenger.call('ClientStateController:getState'); -const isOpen = clientStateControllerSelectors.selectIsClientOpen(state); -``` - -## Contributing - -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/client-state-controller/src/ClientStateController.test.ts b/packages/client-state-controller/src/ClientStateController.test.ts deleted file mode 100644 index 7b980b53ca3..00000000000 --- a/packages/client-state-controller/src/ClientStateController.test.ts +++ /dev/null @@ -1,267 +0,0 @@ -import { Messenger } from '@metamask/messenger'; - -import type { - ClientStateControllerActions, - ClientStateControllerEvents, - ClientStateControllerMessenger, -} from './ClientStateController'; -import { - ClientStateController, - controllerName, - getDefaultClientStateControllerState, -} from './ClientStateController'; -import { clientStateControllerSelectors } from './selectors'; - -describe('ClientStateController', () => { - type RootMessenger = Messenger< - 'Root', - ClientStateControllerActions, - ClientStateControllerEvents - >; - - /** - * Constructs the root messenger. - * - * @returns The root messenger. - */ - function getRootMessenger(): RootMessenger { - return new Messenger< - 'Root', - ClientStateControllerActions, - ClientStateControllerEvents - >({ namespace: 'Root' }); - } - - /** - * Constructs the messenger for the ClientStateController. - * - * @param rootMessenger - The root messenger. - * @returns The controller-specific messenger. - */ - function getMessenger( - rootMessenger: RootMessenger, - ): ClientStateControllerMessenger { - return new Messenger< - typeof controllerName, - ClientStateControllerActions, - ClientStateControllerEvents, - RootMessenger - >({ - namespace: controllerName, - parent: rootMessenger, - }); - } - - type WithControllerCallback = (payload: { - controller: ClientStateController; - rootMessenger: RootMessenger; - messenger: ClientStateControllerMessenger; - }) => Promise | ReturnValue; - - type WithControllerOptions = { - options: Partial[0]>; - }; - - /** - * Wraps tests for the controller by creating the controller and messengers, - * then calling the test function with them. - * - * @param args - Either a callback, or an options bag + a callback. The - * options bag contains arguments for the controller constructor. The - * callback is called with the new controller, root messenger, and - * controller messenger. - * @returns The return value of the callback. - */ - async function withController( - ...args: - | [WithControllerCallback] - | [WithControllerOptions, WithControllerCallback] - ): Promise { - const [{ options = {} }, testFunction] = - args.length === 2 ? args : [{}, args[0]]; - const rootMessenger = getRootMessenger(); - const messenger = getMessenger(rootMessenger); - const controller = new ClientStateController({ - messenger, - ...options, - }); - return await testFunction({ controller, rootMessenger, messenger }); - } - - describe('constructor', () => { - it('initializes with default state (client closed)', async () => { - await withController(({ controller }) => { - expect(controller.state.isClientOpen).toBe(false); - expect( - clientStateControllerSelectors.selectIsClientOpen(controller.state), - ).toBe(false); - }); - }); - - it('allows initializing with partial state', async () => { - await withController( - { options: { state: { isClientOpen: true } } }, - ({ controller }) => { - expect(controller.state.isClientOpen).toBe(true); - expect( - clientStateControllerSelectors.selectIsClientOpen(controller.state), - ).toBe(true); - }, - ); - }); - - it('merges partial state with defaults', async () => { - await withController({ options: { state: {} } }, ({ controller }) => { - expect(controller.state.isClientOpen).toBe(false); - }); - }); - }); - - describe('setClientOpen', () => { - it('updates isClientOpen in state to the given value', async () => { - await withController(({ controller }) => { - controller.setClientOpen(true); - - expect(controller.state.isClientOpen).toBe(true); - expect( - clientStateControllerSelectors.selectIsClientOpen(controller.state), - ).toBe(true); - - controller.setClientOpen(false); - - expect(controller.state.isClientOpen).toBe(false); - expect( - clientStateControllerSelectors.selectIsClientOpen(controller.state), - ).toBe(false); - }); - }); - - it('publishes stateChange event when isClientOpen changes to true', async () => { - await withController(({ controller, messenger }) => { - const listener = jest.fn(); - messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientOpen(true); - - expect(listener).toHaveBeenCalledTimes(1); - const [newState] = listener.mock.calls[0]; - expect(newState.isClientOpen).toBe(true); - }); - }); - - it('publishes stateChange event when isClientOpen changes to false', async () => { - await withController(({ controller, messenger }) => { - controller.setClientOpen(true); - const listener = jest.fn(); - messenger.subscribe(`${controllerName}:stateChange`, listener); - controller.setClientOpen(false); - - expect(listener).toHaveBeenCalledTimes(1); - const [newState] = listener.mock.calls[0]; - expect(newState.isClientOpen).toBe(false); - }); - }); - }); - - describe('selectIsClientOpen selector', () => { - it('returns true when client is open', async () => { - await withController(({ controller }) => { - controller.setClientOpen(true); - expect( - clientStateControllerSelectors.selectIsClientOpen(controller.state), - ).toBe(true); - }); - }); - - it('returns false when client is closed', async () => { - await withController(({ controller }) => { - expect( - clientStateControllerSelectors.selectIsClientOpen(controller.state), - ).toBe(false); - }); - }); - }); - - describe('messenger actions', () => { - it('allows getting state via messenger action', async () => { - await withController(({ controller, messenger }) => { - controller.setClientOpen(true); - const state = messenger.call(`${controllerName}:getState`); - expect(state.isClientOpen).toBe(true); - }); - }); - - it('allows setting client open via messenger action', async () => { - await withController(({ controller, messenger }) => { - messenger.call(`${controllerName}:setClientOpen`, true); - expect(controller.state.isClientOpen).toBe(true); - }); - }); - - it('allows setting client closed via messenger action', async () => { - await withController(({ controller, messenger }) => { - controller.setClientOpen(true); - messenger.call(`${controllerName}:setClientOpen`, false); - expect(controller.state.isClientOpen).toBe(false); - }); - }); - - it('publishes stateChange when setting via messenger action', async () => { - await withController(({ messenger }) => { - const listener = jest.fn(); - messenger.subscribe(`${controllerName}:stateChange`, listener); - messenger.call(`${controllerName}:setClientOpen`, true); - - expect(listener).toHaveBeenCalledTimes(1); - const [newState] = listener.mock.calls[0]; - expect(newState.isClientOpen).toBe(true); - }); - }); - }); - - describe('getDefaultClientStateControllerState', () => { - it('returns default state with client closed', () => { - const defaultState = getDefaultClientStateControllerState(); - - expect(defaultState.isClientOpen).toBe(false); - }); - }); - - describe('selectors', () => { - describe('selectIsClientOpen', () => { - it('returns true when client is open', () => { - expect( - clientStateControllerSelectors.selectIsClientOpen({ - isClientOpen: true, - }), - ).toBe(true); - }); - - it('returns false when client is closed', () => { - expect( - clientStateControllerSelectors.selectIsClientOpen({ - isClientOpen: false, - }), - ).toBe(false); - }); - }); - }); - - describe('lifecycle scenarios', () => { - it('handles multiple open/close cycles', async () => { - await withController(({ controller, messenger }) => { - const listener = jest.fn(); - messenger.subscribe(`${controllerName}:stateChange`, listener); - - controller.setClientOpen(true); - controller.setClientOpen(false); - controller.setClientOpen(true); - controller.setClientOpen(false); - - expect(listener).toHaveBeenCalledTimes(4); - expect( - clientStateControllerSelectors.selectIsClientOpen(controller.state), - ).toBe(false); - }); - }); - }); -}); diff --git a/packages/client-state-controller/src/ClientStateController.ts b/packages/client-state-controller/src/ClientStateController.ts deleted file mode 100644 index 5b53d681c9d..00000000000 --- a/packages/client-state-controller/src/ClientStateController.ts +++ /dev/null @@ -1,210 +0,0 @@ -import type { - StateMetadata, - ControllerGetStateAction, - ControllerStateChangeEvent, -} from '@metamask/base-controller'; -import { BaseController } from '@metamask/base-controller'; -import type { Messenger } from '@metamask/messenger'; - -// === GENERAL === - -/** - * The name of the {@link ClientStateController}. - */ -export const controllerName = 'ClientStateController'; - -// === STATE === - -/** - * Describes the shape of the state object for {@link ClientStateController}. - */ -export type ClientStateControllerState = { - /** - * Whether the client (UI) is currently open. - */ - isClientOpen: boolean; -}; - -/** - * Constructs the default {@link ClientStateController} state. - * - * @returns The default {@link ClientStateController} state. - */ -export function getDefaultClientStateControllerState(): ClientStateControllerState { - return { - isClientOpen: false, - }; -} - -/** - * The metadata for each property in {@link ClientStateControllerState}. - */ -const controllerMetadata = { - isClientOpen: { - includeInDebugSnapshot: true, - includeInStateLogs: true, - persist: false, - usedInUi: false, - }, -} satisfies StateMetadata; - -// === MESSENGER === - -/** - * Retrieves the state of the {@link ClientStateController}. - */ -export type ClientStateControllerGetStateAction = ControllerGetStateAction< - typeof controllerName, - ClientStateControllerState ->; - -/** - * Sets whether the client (UI) is open. - */ -export type ClientStateControllerSetClientOpenAction = { - type: `${typeof controllerName}:setClientOpen`; - handler: (open: boolean) => void; -}; - -/** - * Actions that {@link ClientStateController} exposes. - */ -export type ClientStateControllerActions = - | ClientStateControllerGetStateAction - | ClientStateControllerSetClientOpenAction; - -/** - * Actions from other messengers that {@link ClientStateController} calls. - */ -type AllowedActions = never; - -/** - * Published when the state of {@link ClientStateController} changes. - */ -export type ClientStateControllerStateChangeEvent = ControllerStateChangeEvent< - typeof controllerName, - ClientStateControllerState ->; - -/** - * Events that {@link ClientStateController} exposes. - */ -export type ClientStateControllerEvents = ClientStateControllerStateChangeEvent; - -/** - * Events from other messengers that {@link ClientStateController} subscribes to. - */ -type AllowedEvents = never; - -/** - * The messenger for {@link ClientStateController}. - */ -export type ClientStateControllerMessenger = Messenger< - typeof controllerName, - ClientStateControllerActions | AllowedActions, - ClientStateControllerEvents | AllowedEvents ->; - -// === CONTROLLER DEFINITION === - -/** - * The options for constructing a {@link ClientStateController}. - */ -export type ClientStateControllerOptions = { - /** - * The messenger suited for this controller. - */ - messenger: ClientStateControllerMessenger; - /** - * The initial state to set on this controller. - */ - state?: Partial; -}; - -/** - * `ClientStateController` manages the application lifecycle state. - * - * This controller tracks whether the client (UI) is open and publishes state - * change events that other controllers can subscribe to for adjusting their behavior. - * - * **Use cases:** - * - Polling controllers can stop when client closes, start when it opens - * - WebSocket connections can disconnect when closed, reconnect when opened - * - Real-time subscriptions can pause when not visible - * - * **Platform Integration:** - * Platform code should call `ClientStateController:setClientOpen` via messenger. - * - * @example - * ```typescript - * // In MetamaskController or platform code - * set isClientOpen(open) { - * this.controllerMessenger.call('ClientStateController:setClientOpen', open); - * } - * - * // Consumer controller subscribing to state changes - * class MyController extends BaseController { - * constructor({ messenger }) { - * super({ messenger, ... }); - * - * messenger.subscribe( - * 'ClientStateController:stateChange', - * (newState) => { - * if (newState.isClientOpen) { - * this.startPolling(); - * } else { - * this.stopPolling(); - * } - * }, - * ); - * } - * } - * ``` - */ -export class ClientStateController extends BaseController< - typeof controllerName, - ClientStateControllerState, - ClientStateControllerMessenger -> { - /** - * Constructs a new {@link ClientStateController}. - * - * @param options - The constructor options. - * @param options.messenger - The messenger suited for this controller. - * @param options.state - The initial state to set on this controller. - */ - constructor({ messenger, state = {} }: ClientStateControllerOptions) { - super({ - messenger, - metadata: controllerMetadata, - name: controllerName, - state: { - ...getDefaultClientStateControllerState(), - ...state, - }, - }); - - // Register the setClientOpen action - this.messenger.registerActionHandler( - `ClientStateController:setClientOpen`, - this.setClientOpen.bind(this), - ); - } - - /** - * Sets whether the client (UI) is open. - * - * This method should be called via messenger when the UI opens or closes. - * State changes trigger the standard `stateChange` event that other controllers - * can subscribe to. - * - * @param open - Whether the client is open. - */ - setClientOpen(open: boolean): void { - if (this.state.isClientOpen !== open) { - this.update((state) => { - state.isClientOpen = open; - }); - } - } -} diff --git a/packages/client-state-controller/src/index.ts b/packages/client-state-controller/src/index.ts deleted file mode 100644 index 0de3bfe90ae..00000000000 --- a/packages/client-state-controller/src/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -export { - ClientStateController, - getDefaultClientStateControllerState, -} from './ClientStateController'; -export { clientStateControllerSelectors } from './selectors'; - -export type { - ClientStateControllerState, - ClientStateControllerOptions, - ClientStateControllerGetStateAction, - ClientStateControllerSetClientOpenAction, - ClientStateControllerActions, - ClientStateControllerStateChangeEvent, - ClientStateControllerEvents, - ClientStateControllerMessenger, -} from './ClientStateController'; diff --git a/packages/client-state-controller/src/selectors.ts b/packages/client-state-controller/src/selectors.ts deleted file mode 100644 index 46c150854f4..00000000000 --- a/packages/client-state-controller/src/selectors.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { ClientStateControllerState } from './ClientStateController'; - -/** - * Selects whether the client is currently open. - * - * @param state - The ClientStateController state. - * @returns True if the client is open. - */ -const selectIsClientOpen = (state: ClientStateControllerState): boolean => - state.isClientOpen; - -/** - * Selectors for the ClientStateController state. - * These can be used with Redux or directly with controller state. - */ -export const clientStateControllerSelectors = { - selectIsClientOpen, -}; diff --git a/packages/ui-state-controller/CHANGELOG.md b/packages/ui-state-controller/CHANGELOG.md new file mode 100644 index 00000000000..81baed155d6 --- /dev/null +++ b/packages/ui-state-controller/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `@metamask/ui-state-controller` ([#7808](https://github.com/MetaMask/core/pull/7808)) + - `UiStateController` for managing client (UI) open/closed state (formerly `ClientStateController`) + - `UiStateController:setClientOpen` messenger action for platform code to call + - `UiStateController:stateChange` event for controllers to subscribe to lifecycle changes + - `isUiOpen` state property (not persisted - always starts as `false`) + - `uiStateControllerSelectors.selectIsUiOpen` selector for derived state access + - Full TypeScript support with exported types + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/client-state-controller/LICENSE b/packages/ui-state-controller/LICENSE similarity index 100% rename from packages/client-state-controller/LICENSE rename to packages/ui-state-controller/LICENSE diff --git a/packages/ui-state-controller/README.md b/packages/ui-state-controller/README.md new file mode 100644 index 00000000000..f822945ba70 --- /dev/null +++ b/packages/ui-state-controller/README.md @@ -0,0 +1,151 @@ +# `@metamask/ui-state-controller` + +Provides a centralized way for controllers to respond to application lifecycle changes. + +## Installation + +`yarn add @metamask/ui-state-controller` + +or + +`npm install @metamask/ui-state-controller` + +## Usage + +### Basic Setup + +```typescript +import { Messenger } from '@metamask/messenger'; +import { + UiStateController, + UiStateControllerActions, + UiStateControllerEvents, +} from '@metamask/ui-state-controller'; + +const rootMessenger = new Messenger< + 'Root', + UiStateControllerActions, + UiStateControllerEvents +>({ namespace: 'Root' }); + +const controllerMessenger = new Messenger({ + namespace: 'UiStateController', + parent: rootMessenger, +}); + +const uiStateController = new UiStateController({ + messenger: controllerMessenger, +}); +``` + +### Platform Integration + +Platform code calls `UiStateController:setUiOpen` when the UI is opened or closed: + +```text +onUiOpened() { + controllerMessenger.call('UiStateController:setUiOpen', true); +} + +onUiClosed() { + controllerMessenger.call('UiStateController:setUiOpen', false); +} +``` + +### Consumer controller and using with other lifecycle state (e.g. Keyring unlock/lock) + +Use `UiStateController:stateChange` only for behavior that **must** run when the UI is open or closed (e.g., pausing/resuming a critical background task). **Use the selector** when subscribing so the handler receives a single derived value (e.g. `isUiOpen`), and **prefer pause/resume** over stop/start for polling. + +UI open/close alone is usually not enough to decide when to start or stop work. Combine `UiStateController:stateChange` with other lifecycle events, such as **KeyringController:unlock** / **KeyringController:lock** (or any controller that expresses "ready for background work"). Only start subscriptions, polling, or network requests when **both** the UI is open and the keyring (or equivalent) is unlocked; stop or pause when the UI closes **or** the keyring locks. + +#### Important: Usage guidelines and warnings + +**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 `isUiOpen` becomes true can lead to unnecessary network traffic and battery use, requests before onboarding is complete (a recurring source of issues), and poor performance as more features are added. + +**Use this controller responsibly:** + +- Start only the subscriptions, polling, or requests that are **needed for the current screen or flow** +- Do **not** start network-dependent or heavy behavior solely because `UiStateController:stateChange` reported `isUiOpen: true` +- Consider **deferring** non-critical updates until the user has completed onboarding or reached a screen that needs that data +- 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 +- **Combine with Keyring unlock/lock:** Only start work when it is appropriate for both UI open state and wallet state (e.g. client open **and** keyring unlocked) +- **Prefer pause/resume over stop/start for polling** so you can resume without full re-initialization. Use the selector when subscribing (see example below). + +```typescript +import { uiStateControllerSelectors } from '@metamask/ui-state-controller'; + +class SomeDataController extends BaseController { + #uiOpen = false; + #keyringUnlocked = false; + + constructor({ messenger }) { + super({ messenger, ... }); + + messenger.subscribe( + 'UiStateController:stateChange', + (isUiOpen) => { + this.#uiOpen = isUiOpen; + this.updateActive(); + }, + uiStateControllerSelectors.selectIsUiOpen, + ); + + messenger.subscribe('KeyringController:unlock', () => { + this.#keyringUnlocked = true; + this.updateActive(); + }); + + messenger.subscribe('KeyringController:lock', () => { + this.#keyringUnlocked = false; + this.updateActive(); + }); + } + + updateActive() { + const shouldRun = this.#uiOpen && this.#keyringUnlocked; + if (shouldRun) { + this.resume(); + } else { + this.pause(); + } + } +} +``` + +Note: `stateChange` emits `[state, patches]`; the selector receives the full payload and returns the value passed to the handler (here, `isUiOpen`). + +## API Reference + +### State + +| Property | Type | Description | +| ---------- | --------- | ------------------------------------------ | +| `isUiOpen` | `boolean` | Whether the client (UI) is currently open. | + +Note: State is not persisted. It always starts as `false`. + +### Actions + +| Action | Parameters | Description | +| ----------------------------- | --------------- | ---------------------------- | +| `UiStateController:getState` | none | Returns current state. | +| `UiStateController:setUiOpen` | `open: boolean` | Sets whether the UI is open. | + +### Events + +| Event | Payload | Description | +| ------------------------------- | ------------------ | ---------------------------- | +| `UiStateController:stateChange` | `[state, patches]` | Standard state change event. | + +### Selectors + +```typescript +import { uiStateControllerSelectors } from '@metamask/ui-state-controller'; + +const state = messenger.call('UiStateController:getState'); +const isOpen = uiStateControllerSelectors.selectIsUiOpen(state); +``` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/client-state-controller/jest.config.js b/packages/ui-state-controller/jest.config.js similarity index 100% rename from packages/client-state-controller/jest.config.js rename to packages/ui-state-controller/jest.config.js diff --git a/packages/client-state-controller/package.json b/packages/ui-state-controller/package.json similarity index 92% rename from packages/client-state-controller/package.json rename to packages/ui-state-controller/package.json index a7cc3da6268..a24f38c9801 100644 --- a/packages/client-state-controller/package.json +++ b/packages/ui-state-controller/package.json @@ -1,12 +1,12 @@ { - "name": "@metamask/client-state-controller", + "name": "@metamask/ui-state-controller", "version": "0.0.0", - "description": "Tracks and manages the lifecycle state of MetaMask as a client.", + "description": "Tracks and manages the lifecycle state of MetaMask as an application", "keywords": [ "MetaMask", "Ethereum" ], - "homepage": "https://github.com/MetaMask/core/tree/main/packages/client-state-controller#readme", + "homepage": "https://github.com/MetaMask/core/tree/main/packages/ui-state-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, @@ -38,8 +38,8 @@ "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", + "changelog:update": "../../scripts/update-changelog.sh @metamask/ui-state-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/ui-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", diff --git a/packages/ui-state-controller/src/UiStateController-method-action-types.ts b/packages/ui-state-controller/src/UiStateController-method-action-types.ts new file mode 100644 index 00000000000..410c4c09269 --- /dev/null +++ b/packages/ui-state-controller/src/UiStateController-method-action-types.ts @@ -0,0 +1,25 @@ +/** + * This file is auto generated by `scripts/generate-method-action-types.ts`. + * Do not edit manually. + */ + +import type { UiStateController } from './UiStateController'; + +/** + * Updates state with whether the MetaMask UI is open. + * + * This method should be called when the user has opened the first window or + * screen containing the MetaMask UI, or closed the last window or screen + * containing the MetaMask UI. + * + * @param open - Whether the MetaMask UI is open. + */ +export type UiStateControllerSetUiOpenAction = { + type: `UiStateController:setUiOpen`; + handler: UiStateController['setUiOpen']; +}; + +/** + * Union of all UiStateController action types. + */ +export type UiStateControllerMethodActions = UiStateControllerSetUiOpenAction; diff --git a/packages/ui-state-controller/src/UiStateController.test.ts b/packages/ui-state-controller/src/UiStateController.test.ts new file mode 100644 index 00000000000..25746e10437 --- /dev/null +++ b/packages/ui-state-controller/src/UiStateController.test.ts @@ -0,0 +1,189 @@ +import { Messenger } from '@metamask/messenger'; + +import { uiStateControllerSelectors } from './selectors'; +import type { + UiStateControllerActions, + UiStateControllerEvents, + UiStateControllerMessenger, +} from './UiStateController'; +import { + UiStateController, + controllerName, + getDefaultUiStateControllerState, +} from './UiStateController'; + +describe('UiStateController', () => { + type RootMessenger = Messenger< + 'Root', + UiStateControllerActions, + UiStateControllerEvents + >; + + /** + * Constructs the root messenger. + * + * @returns The root messenger. + */ + function getRootMessenger(): RootMessenger { + return new Messenger< + 'Root', + UiStateControllerActions, + UiStateControllerEvents + >({ namespace: 'Root' }); + } + + /** + * Constructs the messenger for the UiStateController. + * + * @param rootMessenger - The root messenger. + * @returns The controller-specific messenger. + */ + function getMessenger( + rootMessenger: RootMessenger, + ): UiStateControllerMessenger { + return new Messenger< + typeof controllerName, + UiStateControllerActions, + UiStateControllerEvents, + RootMessenger + >({ + namespace: controllerName, + parent: rootMessenger, + }); + } + + type WithControllerCallback = (payload: { + controller: UiStateController; + rootMessenger: RootMessenger; + messenger: UiStateControllerMessenger; + }) => Promise | ReturnValue; + + type WithControllerOptions = { + options: Partial[0]>; + }; + + /** + * Wraps tests for the controller by creating the controller and messengers, + * then calling the test function with them. + * + * @param args - Either a callback, or an options bag + a callback. The + * options bag contains arguments for the controller constructor. The + * callback is called with the new controller, root messenger, and + * controller messenger. + * @returns The return value of the callback. + */ + async function withController( + ...args: + | [WithControllerCallback] + | [WithControllerOptions, WithControllerCallback] + ): Promise { + const [{ options = {} }, testFunction] = + args.length === 2 ? args : [{}, args[0]]; + const rootMessenger = getRootMessenger(); + const messenger = getMessenger(rootMessenger); + const controller = new UiStateController({ + messenger, + ...options, + }); + return await testFunction({ controller, rootMessenger, messenger }); + } + + describe('constructor', () => { + it('initializes with default state (client closed)', async () => { + await withController(({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(` + Object { + "isUiOpen": false, + } + `); + }); + }); + + it('allows initializing with partial state', async () => { + const givenState = { isUiOpen: true }; + await withController( + { options: { state: givenState } }, + ({ controller }) => { + expect(controller.state).toStrictEqual(givenState); + }, + ); + }); + + it('merges partial state with defaults', async () => { + await withController({ options: { state: {} } }, ({ controller }) => { + expect(controller.state).toMatchInlineSnapshot(` + Object { + "isUiOpen": false, + } + `); + }); + }); + }); + + describe('setUiOpen', () => { + it('updates isUiOpen in state to the given value', async () => { + await withController(({ controller }) => { + controller.setUiOpen(true); + + expect(controller.state).toMatchInlineSnapshot(` + Object { + "isUiOpen": true, + } + `); + + controller.setUiOpen(false); + + expect(controller.state).toMatchInlineSnapshot(` + Object { + "isUiOpen": false, + } + `); + }); + }); + }); + + describe('messenger actions', () => { + it('allows setting client open via messenger action', async () => { + await withController(({ controller, messenger }) => { + messenger.call(`${controllerName}:setUiOpen`, true); + expect(controller.state).toStrictEqual({ isUiOpen: true }); + }); + }); + + it('allows setting client closed via messenger action', async () => { + await withController(({ controller, messenger }) => { + controller.setUiOpen(true); + messenger.call(`${controllerName}:setUiOpen`, false); + expect(controller.state).toStrictEqual({ isUiOpen: false }); + }); + }); + }); + + describe('getDefaultUiStateControllerState', () => { + it('returns default state with client closed', () => { + const defaultState = getDefaultUiStateControllerState(); + + expect(defaultState.isUiOpen).toBe(false); + }); + }); + + describe('selectors', () => { + describe('selectIsUiOpen', () => { + it('returns true when client is open', () => { + expect( + uiStateControllerSelectors.selectIsUiOpen({ + isUiOpen: true, + }), + ).toBe(true); + }); + + it('returns false when client is closed', () => { + expect( + uiStateControllerSelectors.selectIsUiOpen({ + isUiOpen: false, + }), + ).toBe(false); + }); + }); + }); +}); diff --git a/packages/ui-state-controller/src/UiStateController.ts b/packages/ui-state-controller/src/UiStateController.ts new file mode 100644 index 00000000000..43e726b3ab5 --- /dev/null +++ b/packages/ui-state-controller/src/UiStateController.ts @@ -0,0 +1,214 @@ +import type { + StateMetadata, + ControllerGetStateAction, + ControllerStateChangeEvent, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; +import type { Messenger } from '@metamask/messenger'; + +import type { UiStateControllerMethodActions } from './UiStateController-method-action-types'; + +// === GENERAL === + +/** + * The name of the {@link UiStateController}. + */ +export const controllerName = 'UiStateController'; + +// === STATE === + +/** + * Describes the shape of the state object for {@link UiStateController}. + */ +export type UiStateControllerState = { + /** + * Whether the user has opened at least one window or screen + * containing the MetaMask UI. These windows or screens may or + * may not be in an inactive state. + */ + isUiOpen: boolean; +}; + +/** + * Constructs the default {@link UiStateController} state. + * + * @returns The default {@link UiStateController} state. + */ +export function getDefaultUiStateControllerState(): UiStateControllerState { + return { + isUiOpen: false, + }; +} + +/** + * The metadata for each property in {@link UiStateControllerState}. + */ +const controllerMetadata = { + isUiOpen: { + includeInDebugSnapshot: true, + includeInStateLogs: true, + persist: false, + usedInUi: false, + }, +} satisfies StateMetadata; + +// === MESSENGER === + +const MESSENGER_EXPOSED_METHODS = ['setUiOpen'] as const; + +/** + * Retrieves the state of the {@link UiStateController}. + */ +export type UiStateControllerGetStateAction = ControllerGetStateAction< + typeof controllerName, + UiStateControllerState +>; + +/** + * Actions that {@link UiStateController} exposes. + */ +export type UiStateControllerActions = + | UiStateControllerGetStateAction + | UiStateControllerMethodActions; + +/** + * Actions from other messengers that {@link UiStateController} calls. + */ +type AllowedActions = never; + +/** + * Published when the state of {@link UiStateController} changes. + */ +export type UiStateControllerStateChangeEvent = ControllerStateChangeEvent< + typeof controllerName, + UiStateControllerState +>; + +/** + * Events that {@link UiStateController} exposes. + */ +export type UiStateControllerEvents = UiStateControllerStateChangeEvent; + +/** + * Events from other messengers that {@link UiStateController} subscribes to. + */ +type AllowedEvents = never; + +/** + * The messenger for {@link UiStateController}. + */ +export type UiStateControllerMessenger = Messenger< + typeof controllerName, + UiStateControllerActions | AllowedActions, + UiStateControllerEvents | AllowedEvents +>; + +// === CONTROLLER DEFINITION === + +/** + * The options for constructing a {@link UiStateController}. + */ +export type UiStateControllerOptions = { + /** + * The messenger suited for this controller. + */ + messenger: UiStateControllerMessenger; + /** + * The initial state to set on this controller. + */ + state?: Partial; +}; + +/** + * `UiStateController` manages the application lifecycle state. + * + * This controller tracks whether the MetaMask UI is open and publishes state + * change events that other controllers can subscribe to for adjusting their behavior. + * + * **Use cases:** + * - Polling controllers can pause when the UI closes, resume when it opens + * - WebSocket connections can disconnect when closed, reconnect when opened + * - Real-time subscriptions can pause when not visible + * + * **Platform Integration:** + * Platform code should call `UiStateController:setUiOpen` via messenger. + * + * @example + * ```typescript + * // In MetamaskController or platform code + * onUiOpened() { + * // ... + * this.controllerMessenger.call('UiStateController:setUiOpen', true); + * } + * + * onUiClosed() { + * // ... + * this.controllerMessenger.call('UiStateController:setUiOpen', false); + * } + * + * // Consumer controller subscribing to state changes + * class MyController extends BaseController { + * constructor({ messenger }) { + * super({ messenger, ... }); + * + * messenger.subscribe( + * 'UiStateController:stateChange', + * (isClientOpen) => { + * if (isClientOpen) { + * this.resumePolling(); + * } else { + * this.pausePolling(); + * } + * }, + * uiStateControllerSelectors.selectIsUiOpen, + * ); + * } + * } + * ``` + */ +export class UiStateController extends BaseController< + typeof controllerName, + UiStateControllerState, + UiStateControllerMessenger +> { + /** + * Constructs a new {@link UiStateController}. + * + * @param options - The constructor options. + * @param options.messenger - The messenger suited for this controller. + * @param options.state - The initial state to set on this controller. + */ + constructor({ messenger, state = {} }: UiStateControllerOptions) { + super({ + messenger, + metadata: controllerMetadata, + name: controllerName, + state: { + ...getDefaultUiStateControllerState(), + ...state, + }, + }); + + this.messenger.registerMethodActionHandlers( + this, + MESSENGER_EXPOSED_METHODS, + ); + } + + /** + * Updates state with whether the MetaMask UI is open. + * + * This method should be called when the user has opened the first window or + * screen containing the MetaMask UI, or closed the last window or screen + * containing the MetaMask UI. + * + * @param open - Whether the MetaMask UI is open. + */ + setUiOpen(open: boolean): void { + if (this.state.isUiOpen !== open) { + this.update((state) => { + state.isUiOpen = open; + }); + } + } +} diff --git a/packages/ui-state-controller/src/index.ts b/packages/ui-state-controller/src/index.ts new file mode 100644 index 00000000000..47edf6739c0 --- /dev/null +++ b/packages/ui-state-controller/src/index.ts @@ -0,0 +1,19 @@ +export { + UiStateController, + getDefaultUiStateControllerState, +} from './UiStateController'; +export { uiStateControllerSelectors } from './selectors'; + +export type { + UiStateControllerState, + UiStateControllerOptions, + UiStateControllerGetStateAction, + UiStateControllerActions, + UiStateControllerStateChangeEvent, + UiStateControllerEvents, + UiStateControllerMessenger, +} from './UiStateController'; +export type { + UiStateControllerSetUiOpenAction, + UiStateControllerMethodActions, +} from './UiStateController-method-action-types'; diff --git a/packages/ui-state-controller/src/selectors.ts b/packages/ui-state-controller/src/selectors.ts new file mode 100644 index 00000000000..d23ce53d229 --- /dev/null +++ b/packages/ui-state-controller/src/selectors.ts @@ -0,0 +1,18 @@ +import type { UiStateControllerState } from './UiStateController'; + +/** + * Selects whether the UI is currently open. + * + * @param state - The UiStateController state. + * @returns True if the UI is open. + */ +const selectIsUiOpen = (state: UiStateControllerState): boolean => + state.isUiOpen; + +/** + * Selectors for the UiStateController state. + * These can be used with Redux or directly with controller state. + */ +export const uiStateControllerSelectors = { + selectIsUiOpen, +}; diff --git a/packages/client-state-controller/tsconfig.build.json b/packages/ui-state-controller/tsconfig.build.json similarity index 100% rename from packages/client-state-controller/tsconfig.build.json rename to packages/ui-state-controller/tsconfig.build.json diff --git a/packages/client-state-controller/tsconfig.json b/packages/ui-state-controller/tsconfig.json similarity index 100% rename from packages/client-state-controller/tsconfig.json rename to packages/ui-state-controller/tsconfig.json diff --git a/packages/client-state-controller/typedoc.json b/packages/ui-state-controller/typedoc.json similarity index 100% rename from packages/client-state-controller/typedoc.json rename to packages/ui-state-controller/typedoc.json diff --git a/teams.json b/teams.json index 9ca41147efe..f2d2d29773a 100644 --- a/teams.json +++ b/teams.json @@ -35,7 +35,7 @@ "metamask/multichain-api-middleware": "team-wallet-integrations", "metamask/selected-network-controller": "team-wallet-integrations", "metamask/eip-5792-middleware": "team-wallet-integrations", - "metamask/client-state-controller": "team-core-platform", + "metamask/ui-state-controller": "team-core-platform", "metamask/base-controller": "team-core-platform", "metamask/build-utils": "team-core-platform", "metamask/composable-controller": "team-core-platform", diff --git a/tsconfig.build.json b/tsconfig.build.json index 1fc79029609..22da88fba9d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -59,7 +59,7 @@ "path": "./packages/claims-controller/tsconfig.build.json" }, { - "path": "./packages/client-state-controller/tsconfig.build.json" + "path": "./packages/ui-state-controller/tsconfig.build.json" }, { "path": "./packages/composable-controller/tsconfig.build.json" diff --git a/tsconfig.json b/tsconfig.json index efae5f14f7b..904d3a13f9e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,7 +60,7 @@ "path": "./packages/claims-controller" }, { - "path": "./packages/client-state-controller" + "path": "./packages/ui-state-controller" }, { "path": "./packages/composable-controller" diff --git a/yarn.lock b/yarn.lock index 0754718b58f..5af12c9ce4d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2859,24 +2859,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/client-state-controller@workspace:packages/client-state-controller": - version: 0.0.0-use.local - resolution: "@metamask/client-state-controller@workspace:packages/client-state-controller" - dependencies: - "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" - deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" - typedoc-plugin-missing-exports: "npm:^2.0.0" - typescript: "npm:~5.3.3" - languageName: unknown - linkType: soft - "@metamask/composable-controller@workspace:packages/composable-controller": version: 0.0.0-use.local resolution: "@metamask/composable-controller@workspace:packages/composable-controller" @@ -5102,6 +5084,24 @@ __metadata: languageName: unknown linkType: soft +"@metamask/ui-state-controller@workspace:packages/ui-state-controller": + version: 0.0.0-use.local + resolution: "@metamask/ui-state-controller@workspace:packages/ui-state-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.5.2" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.5" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/user-operation-controller@workspace:packages/user-operation-controller": version: 0.0.0-use.local resolution: "@metamask/user-operation-controller@workspace:packages/user-operation-controller" From 9d1eb16e29ddb09e5009a7fa713aa2600d79bf8f Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Tue, 10 Feb 2026 22:12:08 +0100 Subject: [PATCH 6/7] feat(ClientController): init --- .github/CODEOWNERS | 6 +- .yarnrc.yml | 16 +- packages/client-controller/CHANGELOG.md | 20 ++ .../LICENSE | 0 packages/client-controller/README.md | 180 ++++++++++++++++++ .../jest.config.js | 0 .../package.json | 10 +- .../ClientController-method-action-types.ts} | 12 +- .../src/ClientController.test.ts} | 50 ++--- .../src/ClientController.ts} | 88 ++++----- packages/client-controller/src/index.ts | 19 ++ packages/client-controller/src/selectors.ts | 18 ++ .../tsconfig.build.json | 0 .../tsconfig.json | 0 .../typedoc.json | 0 packages/ui-state-controller/CHANGELOG.md | 20 -- packages/ui-state-controller/README.md | 151 --------------- packages/ui-state-controller/src/index.ts | 19 -- packages/ui-state-controller/src/selectors.ts | 18 -- teams.json | 2 +- tsconfig.build.json | 2 +- tsconfig.json | 2 +- yarn.lock | 36 ++-- 23 files changed, 349 insertions(+), 320 deletions(-) create mode 100644 packages/client-controller/CHANGELOG.md rename packages/{ui-state-controller => client-controller}/LICENSE (100%) create mode 100644 packages/client-controller/README.md rename packages/{ui-state-controller => client-controller}/jest.config.js (100%) rename packages/{ui-state-controller => client-controller}/package.json (90%) rename packages/{ui-state-controller/src/UiStateController-method-action-types.ts => client-controller/src/ClientController-method-action-types.ts} (57%) rename packages/{ui-state-controller/src/UiStateController.test.ts => client-controller/src/ClientController.test.ts} (81%) rename packages/{ui-state-controller/src/UiStateController.ts => client-controller/src/ClientController.ts} (56%) create mode 100644 packages/client-controller/src/index.ts create mode 100644 packages/client-controller/src/selectors.ts rename packages/{ui-state-controller => client-controller}/tsconfig.build.json (100%) rename packages/{ui-state-controller => client-controller}/tsconfig.json (100%) rename packages/{ui-state-controller => client-controller}/typedoc.json (100%) delete mode 100644 packages/ui-state-controller/CHANGELOG.md delete mode 100644 packages/ui-state-controller/README.md delete mode 100644 packages/ui-state-controller/src/index.ts delete mode 100644 packages/ui-state-controller/src/selectors.ts diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f52bf2f8b65..553eb4d1477 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -71,7 +71,6 @@ /packages/eip-5792-middleware @MetaMask/wallet-integrations ## Core Platform Team -/packages/ui-state-controller @MetaMask/core-platform /packages/base-controller @MetaMask/core-platform /packages/build-utils @MetaMask/core-platform /packages/composable-controller @MetaMask/core-platform @@ -109,6 +108,7 @@ /packages/permission-log-controller @MetaMask/wallet-integrations @MetaMask/core-platform /packages/remote-feature-flag-controller @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform /packages/storage-service @MetaMask/extension-platform @MetaMask/mobile-platform @MetaMask/core-platform +/packages/client-controller @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform ## Package Release related /packages/account-tree-controller/package.json @MetaMask/accounts-engineers @MetaMask/core-platform @@ -204,4 +204,6 @@ /packages/claims-controller/package.json @MetaMask/web3auth @MetaMask/core-platform /packages/claims-controller/CHANGELOG.md @MetaMask/web3auth @MetaMask/core-platform /packages/ai-controllers/package.json @MetaMask/social-ai @MetaMask/core-platform -/packages/ai-controllers/CHANGELOG.md @MetaMask/social-ai @MetaMask/core-platform \ No newline at end of file +/packages/ai-controllers/CHANGELOG.md @MetaMask/social-ai @MetaMask/core-platform +/packages/client-controller/package.json @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform +/packages/client-controller/CHANGELOG.md @MetaMask/core-platform @MetaMask/extension-platform @MetaMask/mobile-platform \ No newline at end of file diff --git a/.yarnrc.yml b/.yarnrc.yml index a6812f301e0..adb37f18942 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -2,6 +2,8 @@ compressionLevel: mixed enableGlobalCache: false +enableImmutableInstalls: true + enableScripts: false enableTelemetry: false @@ -12,18 +14,14 @@ logFilters: nodeLinker: node-modules -plugins: - - path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs - spec: "https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js" - -# Configure the NPM minimal age gate to 3 days, meaning packages must be at -# least 3 days old to be installed. -npmMinimalAgeGate: 4320 # 3 days (in minutes) +npmMinimalAgeGate: 4320 -# Override the minimal age gate, allowing certain packages to be installed -# regardless of their publish age. npmPreapprovedPackages: - "@metamask/*" - "@metamask-previews/*" - "@lavamoat/*" - "@ts-bridge/*" + +plugins: + - path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs + spec: "https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js" diff --git a/packages/client-controller/CHANGELOG.md b/packages/client-controller/CHANGELOG.md new file mode 100644 index 00000000000..acc8b869458 --- /dev/null +++ b/packages/client-controller/CHANGELOG.md @@ -0,0 +1,20 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +### Added + +- Initial release of `@metamask/client-controller` ([#7808](https://github.com/MetaMask/core/pull/7808)) + - `ClientController` for managing client (UI) open/closed state + - `ClientController:setUiOpen` messenger action for platform code to call + - `ClientController:stateChange` event for controllers to subscribe to lifecycle changes + - `isUiOpen` state property (not persisted - always starts as `false`) + - `clientControllerSelectors.selectIsUiOpen` selector for derived state access + - Full TypeScript support with exported types + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/ui-state-controller/LICENSE b/packages/client-controller/LICENSE similarity index 100% rename from packages/ui-state-controller/LICENSE rename to packages/client-controller/LICENSE diff --git a/packages/client-controller/README.md b/packages/client-controller/README.md new file mode 100644 index 00000000000..a9912c2a1c6 --- /dev/null +++ b/packages/client-controller/README.md @@ -0,0 +1,180 @@ +# `@metamask/client-controller` + +Client-level state for MetaMask (e.g. whether a UI window is open). Provides a centralized way for controllers to respond to application lifecycle changes. + +## Installation + +```bash +yarn add @metamask/client-controller +``` + +or + +```bash +npm install @metamask/client-controller +``` + +## Usage + +### Basic Setup + +```typescript +import { Messenger } from '@metamask/messenger'; +import { + ClientController, + ClientControllerActions, + ClientControllerEvents, +} from '@metamask/client-controller'; + +const rootMessenger = new Messenger< + 'Root', + ClientControllerActions, + ClientControllerEvents +>({ namespace: 'Root' }); + +const controllerMessenger = new Messenger({ + namespace: 'ClientController', + parent: rootMessenger, +}); + +const clientController = new ClientController({ + messenger: controllerMessenger, +}); +``` + +### Platform Integration + +Platform code calls `ClientController:setUiOpen` when the UI is opened or +closed: + +```text +onUiOpened() { + controllerMessenger.call('ClientController:setUiOpen', true); +} + +onUiClosed() { + controllerMessenger.call('ClientController:setUiOpen', false); +} +``` + +### Consumer controller and using with other lifecycle state (e.g. Keyring unlock/lock) + +Use `ClientController:stateChange` only for behavior that **must** run when the +UI is open or closed (e.g., pausing/resuming a critical background task). **Use +the selector** when subscribing so the handler receives a single derived value +(e.g. `isUiOpen`), and **prefer pause/resume** over stop/start for polling. + +UI open/close alone is usually not enough to decide when to start or stop work. +Combine `ClientController:stateChange` with other lifecycle events, such as +**KeyringController:unlock** / **KeyringController:lock** (or any controller that +expresses "ready for background work"). Only start subscriptions, polling, or +network requests when **both** the UI is open and the keyring (or equivalent) is +unlocked; stop or pause when the UI closes **or** the keyring locks. + +#### Important: Usage guidelines and warnings + +**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 +`isUiOpen` becomes true can lead to unnecessary network traffic and battery +use, requests before onboarding is complete (a recurring source of issues), and +poor performance as more features are added. + +**Use this controller responsibly:** + +- Start only the subscriptions, polling, or requests that are **needed for the + current screen or flow** +- Do **not** start network-dependent or heavy behavior solely because + `ClientController:stateChange` reported `isUiOpen: true` +- Consider **deferring** non-critical updates until the user has completed + onboarding or reached a screen that needs that data +- 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 +- **Combine with Keyring unlock/lock:** Only start work when it is appropriate + for both UI open state and wallet state (e.g. client open **and** keyring + unlocked) +- **Prefer pause/resume over stop/start for polling** so you can resume without + full re-initialization. Use the selector when subscribing (see example + below). + +```typescript +import { clientControllerSelectors } from '@metamask/client-controller'; + +class SomeDataController extends BaseController { + #uiOpen = false; + #keyringUnlocked = false; + + constructor({ messenger }) { + super({ messenger, ... }); + + messenger.subscribe( + 'ClientController:stateChange', + (isUiOpen) => { + this.#uiOpen = isUiOpen; + this.updateActive(); + }, + clientControllerSelectors.selectIsUiOpen, + ); + + messenger.subscribe('KeyringController:unlock', () => { + this.#keyringUnlocked = true; + this.updateActive(); + }); + + messenger.subscribe('KeyringController:lock', () => { + this.#keyringUnlocked = false; + this.updateActive(); + }); + } + + updateActive() { + const shouldRun = this.#uiOpen && this.#keyringUnlocked; + if (shouldRun) { + this.resume(); + } else { + this.pause(); + } + } +} +``` + +Note: `stateChange` emits `[state, patches]`; the selector receives the full +payload and returns the value passed to the handler (here, `isUiOpen`). + +## API Reference + +### State + +| Property | Type | Description | +| ---------- | --------- | ------------------------------------------ | +| `isUiOpen` | `boolean` | Whether the client (UI) is currently open. | + +State is not persisted. It always starts as `false`. + +### Actions + +| Action | Parameters | Description | +| ---------------------------- | --------------- | ---------------------------- | +| `ClientController:getState` | none | Returns current state. | +| `ClientController:setUiOpen` | `open: boolean` | Sets whether the UI is open. | + +### Events + +| Event | Payload | Description | +| ------------------------------ | ------------------ | ---------------------------- | +| `ClientController:stateChange` | `[state, patches]` | Standard state change event. | + +### Selectors + +```typescript +import { clientControllerSelectors } from '@metamask/client-controller'; + +const state = messenger.call('ClientController:getState'); +const isOpen = clientControllerSelectors.selectIsUiOpen(state); +``` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found +in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/ui-state-controller/jest.config.js b/packages/client-controller/jest.config.js similarity index 100% rename from packages/ui-state-controller/jest.config.js rename to packages/client-controller/jest.config.js diff --git a/packages/ui-state-controller/package.json b/packages/client-controller/package.json similarity index 90% rename from packages/ui-state-controller/package.json rename to packages/client-controller/package.json index a24f38c9801..fae607aa91f 100644 --- a/packages/ui-state-controller/package.json +++ b/packages/client-controller/package.json @@ -1,12 +1,12 @@ { - "name": "@metamask/ui-state-controller", + "name": "@metamask/client-controller", "version": "0.0.0", - "description": "Tracks and manages the lifecycle state of MetaMask as an application", + "description": "Client-level state for MetaMask (e.g. whether a UI window is open)", "keywords": [ "MetaMask", "Ethereum" ], - "homepage": "https://github.com/MetaMask/core/tree/main/packages/ui-state-controller#readme", + "homepage": "https://github.com/MetaMask/core/tree/main/packages/client-controller#readme", "bugs": { "url": "https://github.com/MetaMask/core/issues" }, @@ -38,8 +38,8 @@ "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/ui-state-controller", - "changelog:validate": "../../scripts/validate-changelog.sh @metamask/ui-state-controller", + "changelog:update": "../../scripts/update-changelog.sh @metamask/client-controller", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/client-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", diff --git a/packages/ui-state-controller/src/UiStateController-method-action-types.ts b/packages/client-controller/src/ClientController-method-action-types.ts similarity index 57% rename from packages/ui-state-controller/src/UiStateController-method-action-types.ts rename to packages/client-controller/src/ClientController-method-action-types.ts index 410c4c09269..395b224ee67 100644 --- a/packages/ui-state-controller/src/UiStateController-method-action-types.ts +++ b/packages/client-controller/src/ClientController-method-action-types.ts @@ -3,7 +3,7 @@ * Do not edit manually. */ -import type { UiStateController } from './UiStateController'; +import type { ClientController } from './ClientController'; /** * Updates state with whether the MetaMask UI is open. @@ -14,12 +14,12 @@ import type { UiStateController } from './UiStateController'; * * @param open - Whether the MetaMask UI is open. */ -export type UiStateControllerSetUiOpenAction = { - type: `UiStateController:setUiOpen`; - handler: UiStateController['setUiOpen']; +export type ClientControllerSetUiOpenAction = { + type: `ClientController:setUiOpen`; + handler: ClientController['setUiOpen']; }; /** - * Union of all UiStateController action types. + * Union of all ClientController action types. */ -export type UiStateControllerMethodActions = UiStateControllerSetUiOpenAction; +export type ClientControllerMethodActions = ClientControllerSetUiOpenAction; diff --git a/packages/ui-state-controller/src/UiStateController.test.ts b/packages/client-controller/src/ClientController.test.ts similarity index 81% rename from packages/ui-state-controller/src/UiStateController.test.ts rename to packages/client-controller/src/ClientController.test.ts index 25746e10437..31e6d7dd498 100644 --- a/packages/ui-state-controller/src/UiStateController.test.ts +++ b/packages/client-controller/src/ClientController.test.ts @@ -1,22 +1,22 @@ import { Messenger } from '@metamask/messenger'; -import { uiStateControllerSelectors } from './selectors'; import type { - UiStateControllerActions, - UiStateControllerEvents, - UiStateControllerMessenger, -} from './UiStateController'; + ClientControllerActions, + ClientControllerEvents, + ClientControllerMessenger, +} from './ClientController'; import { - UiStateController, + ClientController, controllerName, - getDefaultUiStateControllerState, -} from './UiStateController'; + getDefaultClientControllerState, +} from './ClientController'; +import { clientControllerSelectors } from './selectors'; -describe('UiStateController', () => { +describe('ClientController', () => { type RootMessenger = Messenger< 'Root', - UiStateControllerActions, - UiStateControllerEvents + ClientControllerActions, + ClientControllerEvents >; /** @@ -27,24 +27,24 @@ describe('UiStateController', () => { function getRootMessenger(): RootMessenger { return new Messenger< 'Root', - UiStateControllerActions, - UiStateControllerEvents + ClientControllerActions, + ClientControllerEvents >({ namespace: 'Root' }); } /** - * Constructs the messenger for the UiStateController. + * Constructs the messenger for the ClientController. * * @param rootMessenger - The root messenger. * @returns The controller-specific messenger. */ function getMessenger( rootMessenger: RootMessenger, - ): UiStateControllerMessenger { + ): ClientControllerMessenger { return new Messenger< typeof controllerName, - UiStateControllerActions, - UiStateControllerEvents, + ClientControllerActions, + ClientControllerEvents, RootMessenger >({ namespace: controllerName, @@ -53,13 +53,13 @@ describe('UiStateController', () => { } type WithControllerCallback = (payload: { - controller: UiStateController; + controller: ClientController; rootMessenger: RootMessenger; - messenger: UiStateControllerMessenger; + messenger: ClientControllerMessenger; }) => Promise | ReturnValue; type WithControllerOptions = { - options: Partial[0]>; + options: Partial[0]>; }; /** @@ -81,7 +81,7 @@ describe('UiStateController', () => { args.length === 2 ? args : [{}, args[0]]; const rootMessenger = getRootMessenger(); const messenger = getMessenger(rootMessenger); - const controller = new UiStateController({ + const controller = new ClientController({ messenger, ...options, }); @@ -159,9 +159,9 @@ describe('UiStateController', () => { }); }); - describe('getDefaultUiStateControllerState', () => { + describe('getDefaultClientControllerState', () => { it('returns default state with client closed', () => { - const defaultState = getDefaultUiStateControllerState(); + const defaultState = getDefaultClientControllerState(); expect(defaultState.isUiOpen).toBe(false); }); @@ -171,7 +171,7 @@ describe('UiStateController', () => { describe('selectIsUiOpen', () => { it('returns true when client is open', () => { expect( - uiStateControllerSelectors.selectIsUiOpen({ + clientControllerSelectors.selectIsUiOpen({ isUiOpen: true, }), ).toBe(true); @@ -179,7 +179,7 @@ describe('UiStateController', () => { it('returns false when client is closed', () => { expect( - uiStateControllerSelectors.selectIsUiOpen({ + clientControllerSelectors.selectIsUiOpen({ isUiOpen: false, }), ).toBe(false); diff --git a/packages/ui-state-controller/src/UiStateController.ts b/packages/client-controller/src/ClientController.ts similarity index 56% rename from packages/ui-state-controller/src/UiStateController.ts rename to packages/client-controller/src/ClientController.ts index 43e726b3ab5..d431b1aeda5 100644 --- a/packages/ui-state-controller/src/UiStateController.ts +++ b/packages/client-controller/src/ClientController.ts @@ -6,21 +6,21 @@ import type { import { BaseController } from '@metamask/base-controller'; import type { Messenger } from '@metamask/messenger'; -import type { UiStateControllerMethodActions } from './UiStateController-method-action-types'; +import type { ClientControllerMethodActions } from './ClientController-method-action-types'; // === GENERAL === /** - * The name of the {@link UiStateController}. + * The name of the {@link ClientController}. */ -export const controllerName = 'UiStateController'; +export const controllerName = 'ClientController'; // === STATE === /** - * Describes the shape of the state object for {@link UiStateController}. + * Describes the shape of the state object for {@link ClientController}. */ -export type UiStateControllerState = { +export type ClientControllerState = { /** * Whether the user has opened at least one window or screen * containing the MetaMask UI. These windows or screens may or @@ -30,18 +30,18 @@ export type UiStateControllerState = { }; /** - * Constructs the default {@link UiStateController} state. + * Constructs the default {@link ClientController} state. * - * @returns The default {@link UiStateController} state. + * @returns The default {@link ClientController} state. */ -export function getDefaultUiStateControllerState(): UiStateControllerState { +export function getDefaultClientControllerState(): ClientControllerState { return { isUiOpen: false, }; } /** - * The metadata for each property in {@link UiStateControllerState}. + * The metadata for each property in {@link ClientControllerState}. */ const controllerMetadata = { isUiOpen: { @@ -50,77 +50,77 @@ const controllerMetadata = { persist: false, usedInUi: false, }, -} satisfies StateMetadata; +} satisfies StateMetadata; // === MESSENGER === const MESSENGER_EXPOSED_METHODS = ['setUiOpen'] as const; /** - * Retrieves the state of the {@link UiStateController}. + * Retrieves the state of the {@link ClientController}. */ -export type UiStateControllerGetStateAction = ControllerGetStateAction< +export type ClientControllerGetStateAction = ControllerGetStateAction< typeof controllerName, - UiStateControllerState + ClientControllerState >; /** - * Actions that {@link UiStateController} exposes. + * Actions that {@link ClientController} exposes. */ -export type UiStateControllerActions = - | UiStateControllerGetStateAction - | UiStateControllerMethodActions; +export type ClientControllerActions = + | ClientControllerGetStateAction + | ClientControllerMethodActions; /** - * Actions from other messengers that {@link UiStateController} calls. + * Actions from other messengers that {@link ClientController} calls. */ type AllowedActions = never; /** - * Published when the state of {@link UiStateController} changes. + * Published when the state of {@link ClientController} changes. */ -export type UiStateControllerStateChangeEvent = ControllerStateChangeEvent< +export type ClientControllerStateChangeEvent = ControllerStateChangeEvent< typeof controllerName, - UiStateControllerState + ClientControllerState >; /** - * Events that {@link UiStateController} exposes. + * Events that {@link ClientController} exposes. */ -export type UiStateControllerEvents = UiStateControllerStateChangeEvent; +export type ClientControllerEvents = ClientControllerStateChangeEvent; /** - * Events from other messengers that {@link UiStateController} subscribes to. + * Events from other messengers that {@link ClientController} subscribes to. */ type AllowedEvents = never; /** - * The messenger for {@link UiStateController}. + * The messenger for {@link ClientController}. */ -export type UiStateControllerMessenger = Messenger< +export type ClientControllerMessenger = Messenger< typeof controllerName, - UiStateControllerActions | AllowedActions, - UiStateControllerEvents | AllowedEvents + ClientControllerActions | AllowedActions, + ClientControllerEvents | AllowedEvents >; // === CONTROLLER DEFINITION === /** - * The options for constructing a {@link UiStateController}. + * The options for constructing a {@link ClientController}. */ -export type UiStateControllerOptions = { +export type ClientControllerOptions = { /** * The messenger suited for this controller. */ - messenger: UiStateControllerMessenger; + messenger: ClientControllerMessenger; /** * The initial state to set on this controller. */ - state?: Partial; + state?: Partial; }; /** - * `UiStateController` manages the application lifecycle state. + * `ClientController` manages the application lifecycle state. * * This controller tracks whether the MetaMask UI is open and publishes state * change events that other controllers can subscribe to for adjusting their behavior. @@ -131,19 +131,19 @@ export type UiStateControllerOptions = { * - Real-time subscriptions can pause when not visible * * **Platform Integration:** - * Platform code should call `UiStateController:setUiOpen` via messenger. + * Platform code should call `ClientController:setUiOpen` via messenger. * * @example * ```typescript * // In MetamaskController or platform code * onUiOpened() { * // ... - * this.controllerMessenger.call('UiStateController:setUiOpen', true); + * this.controllerMessenger.call('ClientController:setUiOpen', true); * } * * onUiClosed() { * // ... - * this.controllerMessenger.call('UiStateController:setUiOpen', false); + * this.controllerMessenger.call('ClientController:setUiOpen', false); * } * * // Consumer controller subscribing to state changes @@ -152,7 +152,7 @@ export type UiStateControllerOptions = { * super({ messenger, ... }); * * messenger.subscribe( - * 'UiStateController:stateChange', + * 'ClientController:stateChange', * (isClientOpen) => { * if (isClientOpen) { * this.resumePolling(); @@ -160,31 +160,31 @@ export type UiStateControllerOptions = { * this.pausePolling(); * } * }, - * uiStateControllerSelectors.selectIsUiOpen, + * clientControllerSelectors.selectIsUiOpen, * ); * } * } * ``` */ -export class UiStateController extends BaseController< +export class ClientController extends BaseController< typeof controllerName, - UiStateControllerState, - UiStateControllerMessenger + ClientControllerState, + ClientControllerMessenger > { /** - * Constructs a new {@link UiStateController}. + * Constructs a new {@link ClientController}. * * @param options - The constructor options. * @param options.messenger - The messenger suited for this controller. * @param options.state - The initial state to set on this controller. */ - constructor({ messenger, state = {} }: UiStateControllerOptions) { + constructor({ messenger, state = {} }: ClientControllerOptions) { super({ messenger, metadata: controllerMetadata, name: controllerName, state: { - ...getDefaultUiStateControllerState(), + ...getDefaultClientControllerState(), ...state, }, }); diff --git a/packages/client-controller/src/index.ts b/packages/client-controller/src/index.ts new file mode 100644 index 00000000000..7e885a891f6 --- /dev/null +++ b/packages/client-controller/src/index.ts @@ -0,0 +1,19 @@ +export { + ClientController, + getDefaultClientControllerState, +} from './ClientController'; +export { clientControllerSelectors } from './selectors'; + +export type { + ClientControllerState, + ClientControllerOptions, + ClientControllerGetStateAction, + ClientControllerActions, + ClientControllerStateChangeEvent, + ClientControllerEvents, + ClientControllerMessenger, +} from './ClientController'; +export type { + ClientControllerSetUiOpenAction, + ClientControllerMethodActions, +} from './ClientController-method-action-types'; diff --git a/packages/client-controller/src/selectors.ts b/packages/client-controller/src/selectors.ts new file mode 100644 index 00000000000..a8cefc6c5a3 --- /dev/null +++ b/packages/client-controller/src/selectors.ts @@ -0,0 +1,18 @@ +import type { ClientControllerState } from './ClientController'; + +/** + * Selects whether the UI is currently open. + * + * @param state - The ClientController state. + * @returns True if the UI is open. + */ +const selectIsUiOpen = (state: ClientControllerState): boolean => + state.isUiOpen; + +/** + * Selectors for the ClientController state. + * These can be used with Redux or directly with controller state. + */ +export const clientControllerSelectors = { + selectIsUiOpen, +}; diff --git a/packages/ui-state-controller/tsconfig.build.json b/packages/client-controller/tsconfig.build.json similarity index 100% rename from packages/ui-state-controller/tsconfig.build.json rename to packages/client-controller/tsconfig.build.json diff --git a/packages/ui-state-controller/tsconfig.json b/packages/client-controller/tsconfig.json similarity index 100% rename from packages/ui-state-controller/tsconfig.json rename to packages/client-controller/tsconfig.json diff --git a/packages/ui-state-controller/typedoc.json b/packages/client-controller/typedoc.json similarity index 100% rename from packages/ui-state-controller/typedoc.json rename to packages/client-controller/typedoc.json diff --git a/packages/ui-state-controller/CHANGELOG.md b/packages/ui-state-controller/CHANGELOG.md deleted file mode 100644 index 81baed155d6..00000000000 --- a/packages/ui-state-controller/CHANGELOG.md +++ /dev/null @@ -1,20 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), -and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - -## [Unreleased] - -### Added - -- Initial release of `@metamask/ui-state-controller` ([#7808](https://github.com/MetaMask/core/pull/7808)) - - `UiStateController` for managing client (UI) open/closed state (formerly `ClientStateController`) - - `UiStateController:setClientOpen` messenger action for platform code to call - - `UiStateController:stateChange` event for controllers to subscribe to lifecycle changes - - `isUiOpen` state property (not persisted - always starts as `false`) - - `uiStateControllerSelectors.selectIsUiOpen` selector for derived state access - - Full TypeScript support with exported types - -[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/ui-state-controller/README.md b/packages/ui-state-controller/README.md deleted file mode 100644 index f822945ba70..00000000000 --- a/packages/ui-state-controller/README.md +++ /dev/null @@ -1,151 +0,0 @@ -# `@metamask/ui-state-controller` - -Provides a centralized way for controllers to respond to application lifecycle changes. - -## Installation - -`yarn add @metamask/ui-state-controller` - -or - -`npm install @metamask/ui-state-controller` - -## Usage - -### Basic Setup - -```typescript -import { Messenger } from '@metamask/messenger'; -import { - UiStateController, - UiStateControllerActions, - UiStateControllerEvents, -} from '@metamask/ui-state-controller'; - -const rootMessenger = new Messenger< - 'Root', - UiStateControllerActions, - UiStateControllerEvents ->({ namespace: 'Root' }); - -const controllerMessenger = new Messenger({ - namespace: 'UiStateController', - parent: rootMessenger, -}); - -const uiStateController = new UiStateController({ - messenger: controllerMessenger, -}); -``` - -### Platform Integration - -Platform code calls `UiStateController:setUiOpen` when the UI is opened or closed: - -```text -onUiOpened() { - controllerMessenger.call('UiStateController:setUiOpen', true); -} - -onUiClosed() { - controllerMessenger.call('UiStateController:setUiOpen', false); -} -``` - -### Consumer controller and using with other lifecycle state (e.g. Keyring unlock/lock) - -Use `UiStateController:stateChange` only for behavior that **must** run when the UI is open or closed (e.g., pausing/resuming a critical background task). **Use the selector** when subscribing so the handler receives a single derived value (e.g. `isUiOpen`), and **prefer pause/resume** over stop/start for polling. - -UI open/close alone is usually not enough to decide when to start or stop work. Combine `UiStateController:stateChange` with other lifecycle events, such as **KeyringController:unlock** / **KeyringController:lock** (or any controller that expresses "ready for background work"). Only start subscriptions, polling, or network requests when **both** the UI is open and the keyring (or equivalent) is unlocked; stop or pause when the UI closes **or** the keyring locks. - -#### Important: Usage guidelines and warnings - -**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 `isUiOpen` becomes true can lead to unnecessary network traffic and battery use, requests before onboarding is complete (a recurring source of issues), and poor performance as more features are added. - -**Use this controller responsibly:** - -- Start only the subscriptions, polling, or requests that are **needed for the current screen or flow** -- Do **not** start network-dependent or heavy behavior solely because `UiStateController:stateChange` reported `isUiOpen: true` -- Consider **deferring** non-critical updates until the user has completed onboarding or reached a screen that needs that data -- 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 -- **Combine with Keyring unlock/lock:** Only start work when it is appropriate for both UI open state and wallet state (e.g. client open **and** keyring unlocked) -- **Prefer pause/resume over stop/start for polling** so you can resume without full re-initialization. Use the selector when subscribing (see example below). - -```typescript -import { uiStateControllerSelectors } from '@metamask/ui-state-controller'; - -class SomeDataController extends BaseController { - #uiOpen = false; - #keyringUnlocked = false; - - constructor({ messenger }) { - super({ messenger, ... }); - - messenger.subscribe( - 'UiStateController:stateChange', - (isUiOpen) => { - this.#uiOpen = isUiOpen; - this.updateActive(); - }, - uiStateControllerSelectors.selectIsUiOpen, - ); - - messenger.subscribe('KeyringController:unlock', () => { - this.#keyringUnlocked = true; - this.updateActive(); - }); - - messenger.subscribe('KeyringController:lock', () => { - this.#keyringUnlocked = false; - this.updateActive(); - }); - } - - updateActive() { - const shouldRun = this.#uiOpen && this.#keyringUnlocked; - if (shouldRun) { - this.resume(); - } else { - this.pause(); - } - } -} -``` - -Note: `stateChange` emits `[state, patches]`; the selector receives the full payload and returns the value passed to the handler (here, `isUiOpen`). - -## API Reference - -### State - -| Property | Type | Description | -| ---------- | --------- | ------------------------------------------ | -| `isUiOpen` | `boolean` | Whether the client (UI) is currently open. | - -Note: State is not persisted. It always starts as `false`. - -### Actions - -| Action | Parameters | Description | -| ----------------------------- | --------------- | ---------------------------- | -| `UiStateController:getState` | none | Returns current state. | -| `UiStateController:setUiOpen` | `open: boolean` | Sets whether the UI is open. | - -### Events - -| Event | Payload | Description | -| ------------------------------- | ------------------ | ---------------------------- | -| `UiStateController:stateChange` | `[state, patches]` | Standard state change event. | - -### Selectors - -```typescript -import { uiStateControllerSelectors } from '@metamask/ui-state-controller'; - -const state = messenger.call('UiStateController:getState'); -const isOpen = uiStateControllerSelectors.selectIsUiOpen(state); -``` - -## Contributing - -This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/ui-state-controller/src/index.ts b/packages/ui-state-controller/src/index.ts deleted file mode 100644 index 47edf6739c0..00000000000 --- a/packages/ui-state-controller/src/index.ts +++ /dev/null @@ -1,19 +0,0 @@ -export { - UiStateController, - getDefaultUiStateControllerState, -} from './UiStateController'; -export { uiStateControllerSelectors } from './selectors'; - -export type { - UiStateControllerState, - UiStateControllerOptions, - UiStateControllerGetStateAction, - UiStateControllerActions, - UiStateControllerStateChangeEvent, - UiStateControllerEvents, - UiStateControllerMessenger, -} from './UiStateController'; -export type { - UiStateControllerSetUiOpenAction, - UiStateControllerMethodActions, -} from './UiStateController-method-action-types'; diff --git a/packages/ui-state-controller/src/selectors.ts b/packages/ui-state-controller/src/selectors.ts deleted file mode 100644 index d23ce53d229..00000000000 --- a/packages/ui-state-controller/src/selectors.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { UiStateControllerState } from './UiStateController'; - -/** - * Selects whether the UI is currently open. - * - * @param state - The UiStateController state. - * @returns True if the UI is open. - */ -const selectIsUiOpen = (state: UiStateControllerState): boolean => - state.isUiOpen; - -/** - * Selectors for the UiStateController state. - * These can be used with Redux or directly with controller state. - */ -export const uiStateControllerSelectors = { - selectIsUiOpen, -}; diff --git a/teams.json b/teams.json index f2d2d29773a..bb8112dce3b 100644 --- a/teams.json +++ b/teams.json @@ -35,7 +35,7 @@ "metamask/multichain-api-middleware": "team-wallet-integrations", "metamask/selected-network-controller": "team-wallet-integrations", "metamask/eip-5792-middleware": "team-wallet-integrations", - "metamask/ui-state-controller": "team-core-platform", + "metamask/client-controller": "team-core-platform,team-extension-platform,team-mobile-platform", "metamask/base-controller": "team-core-platform", "metamask/build-utils": "team-core-platform", "metamask/composable-controller": "team-core-platform", diff --git a/tsconfig.build.json b/tsconfig.build.json index 22da88fba9d..ab53d9e4c52 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -59,7 +59,7 @@ "path": "./packages/claims-controller/tsconfig.build.json" }, { - "path": "./packages/ui-state-controller/tsconfig.build.json" + "path": "./packages/client-controller/tsconfig.build.json" }, { "path": "./packages/composable-controller/tsconfig.build.json" diff --git a/tsconfig.json b/tsconfig.json index 904d3a13f9e..509c82b30fc 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -60,7 +60,7 @@ "path": "./packages/claims-controller" }, { - "path": "./packages/ui-state-controller" + "path": "./packages/client-controller" }, { "path": "./packages/composable-controller" diff --git a/yarn.lock b/yarn.lock index 5af12c9ce4d..4fa0cd88d62 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2859,6 +2859,24 @@ __metadata: languageName: unknown linkType: soft +"@metamask/client-controller@workspace:packages/client-controller": + version: 0.0.0-use.local + resolution: "@metamask/client-controller@workspace:packages/client-controller" + dependencies: + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^9.0.0" + "@metamask/messenger": "npm:^0.3.0" + "@ts-bridge/cli": "npm:^0.6.4" + "@types/jest": "npm:^27.5.2" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + ts-jest: "npm:^27.1.5" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.3.3" + languageName: unknown + linkType: soft + "@metamask/composable-controller@workspace:packages/composable-controller": version: 0.0.0-use.local resolution: "@metamask/composable-controller@workspace:packages/composable-controller" @@ -5084,24 +5102,6 @@ __metadata: languageName: unknown linkType: soft -"@metamask/ui-state-controller@workspace:packages/ui-state-controller": - version: 0.0.0-use.local - resolution: "@metamask/ui-state-controller@workspace:packages/ui-state-controller" - dependencies: - "@metamask/auto-changelog": "npm:^3.4.4" - "@metamask/base-controller": "npm:^9.0.0" - "@metamask/messenger": "npm:^0.3.0" - "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" - deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" - typedoc-plugin-missing-exports: "npm:^2.0.0" - typescript: "npm:~5.3.3" - languageName: unknown - linkType: soft - "@metamask/user-operation-controller@workspace:packages/user-operation-controller": version: 0.0.0-use.local resolution: "@metamask/user-operation-controller@workspace:packages/user-operation-controller" From 437f480c088b3fb1082c58bcab8b16fa257b10da Mon Sep 17 00:00:00 2001 From: Kriys94 Date: Tue, 17 Feb 2026 11:32:36 +0100 Subject: [PATCH 7/7] clean code --- .yarnrc.yml | 16 +++++++++------- packages/client-controller/package.json | 9 +++++---- .../src/ClientController.test.ts | 8 ++++---- .../client-controller/src/ClientController.ts | 8 +++----- packages/client-controller/src/index.ts | 5 +---- yarn.lock | 9 +++++---- 6 files changed, 27 insertions(+), 28 deletions(-) diff --git a/.yarnrc.yml b/.yarnrc.yml index adb37f18942..a6812f301e0 100644 --- a/.yarnrc.yml +++ b/.yarnrc.yml @@ -2,8 +2,6 @@ compressionLevel: mixed enableGlobalCache: false -enableImmutableInstalls: true - enableScripts: false enableTelemetry: false @@ -14,14 +12,18 @@ logFilters: nodeLinker: node-modules -npmMinimalAgeGate: 4320 +plugins: + - path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs + spec: "https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js" + +# Configure the NPM minimal age gate to 3 days, meaning packages must be at +# least 3 days old to be installed. +npmMinimalAgeGate: 4320 # 3 days (in minutes) +# Override the minimal age gate, allowing certain packages to be installed +# regardless of their publish age. npmPreapprovedPackages: - "@metamask/*" - "@metamask-previews/*" - "@lavamoat/*" - "@ts-bridge/*" - -plugins: - - path: .yarn/plugins/@yarnpkg/plugin-allow-scripts.cjs - spec: "https://raw.githubusercontent.com/LavaMoat/LavaMoat/main/packages/yarn-plugin-allow-scripts/bundles/@yarnpkg/plugin-allow-scripts.js" diff --git a/packages/client-controller/package.json b/packages/client-controller/package.json index fae607aa91f..44d62e64f2b 100644 --- a/packages/client-controller/package.json +++ b/packages/client-controller/package.json @@ -54,11 +54,12 @@ "devDependencies": { "@metamask/auto-changelog": "^3.4.4", "@ts-bridge/cli": "^0.6.4", - "@types/jest": "^27.5.2", + "@types/jest": "^29.5.14", "deepmerge": "^4.2.2", - "jest": "^27.5.1", - "ts-jest": "^27.1.5", - "typedoc": "^0.24.8", + "jest": "^29.7.0", + "jest-environment-jsdom": "^29.7.0", + "ts-jest": "^29.2.5", + "typedoc": "^0.25.13", "typedoc-plugin-missing-exports": "^2.0.0", "typescript": "~5.3.3" }, diff --git a/packages/client-controller/src/ClientController.test.ts b/packages/client-controller/src/ClientController.test.ts index 31e6d7dd498..c7c7b7cf412 100644 --- a/packages/client-controller/src/ClientController.test.ts +++ b/packages/client-controller/src/ClientController.test.ts @@ -92,7 +92,7 @@ describe('ClientController', () => { it('initializes with default state (client closed)', async () => { await withController(({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { + { "isUiOpen": false, } `); @@ -112,7 +112,7 @@ describe('ClientController', () => { it('merges partial state with defaults', async () => { await withController({ options: { state: {} } }, ({ controller }) => { expect(controller.state).toMatchInlineSnapshot(` - Object { + { "isUiOpen": false, } `); @@ -126,7 +126,7 @@ describe('ClientController', () => { controller.setUiOpen(true); expect(controller.state).toMatchInlineSnapshot(` - Object { + { "isUiOpen": true, } `); @@ -134,7 +134,7 @@ describe('ClientController', () => { controller.setUiOpen(false); expect(controller.state).toMatchInlineSnapshot(` - Object { + { "isUiOpen": false, } `); diff --git a/packages/client-controller/src/ClientController.ts b/packages/client-controller/src/ClientController.ts index d431b1aeda5..78553c1a990 100644 --- a/packages/client-controller/src/ClientController.ts +++ b/packages/client-controller/src/ClientController.ts @@ -205,10 +205,8 @@ export class ClientController extends BaseController< * @param open - Whether the MetaMask UI is open. */ setUiOpen(open: boolean): void { - if (this.state.isUiOpen !== open) { - this.update((state) => { - state.isUiOpen = open; - }); - } + this.update((state) => { + state.isUiOpen = open; + }); } } diff --git a/packages/client-controller/src/index.ts b/packages/client-controller/src/index.ts index 7e885a891f6..8695331b716 100644 --- a/packages/client-controller/src/index.ts +++ b/packages/client-controller/src/index.ts @@ -13,7 +13,4 @@ export type { ClientControllerEvents, ClientControllerMessenger, } from './ClientController'; -export type { - ClientControllerSetUiOpenAction, - ClientControllerMethodActions, -} from './ClientController-method-action-types'; +export type { ClientControllerSetUiOpenAction } from './ClientController-method-action-types'; diff --git a/yarn.lock b/yarn.lock index 4fa0cd88d62..e2ea8e7512b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2867,11 +2867,12 @@ __metadata: "@metamask/base-controller": "npm:^9.0.0" "@metamask/messenger": "npm:^0.3.0" "@ts-bridge/cli": "npm:^0.6.4" - "@types/jest": "npm:^27.5.2" + "@types/jest": "npm:^29.5.14" deepmerge: "npm:^4.2.2" - jest: "npm:^27.5.1" - ts-jest: "npm:^27.1.5" - typedoc: "npm:^0.24.8" + jest: "npm:^29.7.0" + jest-environment-jsdom: "npm:^29.7.0" + ts-jest: "npm:^29.2.5" + typedoc: "npm:^0.25.13" typedoc-plugin-missing-exports: "npm:^2.0.0" typescript: "npm:~5.3.3" languageName: unknown