From cf7325ba00fda47e5ba612f0533579dca0b763b1 Mon Sep 17 00:00:00 2001 From: Danica Shen Date: Thu, 19 Sep 2024 02:20:10 +0100 Subject: [PATCH] feat(25903): Move state management from storage.local to IndexedDB --- app/scripts/background.js | 4 +- app/scripts/lib/local-store.js | 139 ---------- app/scripts/lib/setup-initial-state-hooks.js | 4 +- .../local-store.test.js | 144 +++++----- .../lib/state-management/local-store.ts | 251 ++++++++++++++++++ .../state-management/network-store.test.ts | 73 +++++ .../network-store.ts} | 57 ++-- 7 files changed, 444 insertions(+), 228 deletions(-) delete mode 100644 app/scripts/lib/local-store.js rename app/scripts/lib/{ => state-management}/local-store.test.js (60%) create mode 100644 app/scripts/lib/state-management/local-store.ts create mode 100644 app/scripts/lib/state-management/network-store.test.ts rename app/scripts/lib/{network-store.js => state-management/network-store.ts} (55%) diff --git a/app/scripts/background.js b/app/scripts/background.js index 4fbbee449160..1d633143677d 100644 --- a/app/scripts/background.js +++ b/app/scripts/background.js @@ -59,8 +59,8 @@ import { getCurrentChainId } from '../../ui/selectors'; import migrations from './migrations'; import Migrator from './lib/migrator'; import ExtensionPlatform from './platforms/extension'; -import LocalStore from './lib/local-store'; -import ReadOnlyNetworkStore from './lib/network-store'; +import LocalStore from './lib/state-management/local-store'; +import ReadOnlyNetworkStore from './lib/state-management/network-store'; import { SENTRY_BACKGROUND_STATE } from './constants/sentry-state'; import createStreamSink from './lib/createStreamSink'; diff --git a/app/scripts/lib/local-store.js b/app/scripts/lib/local-store.js deleted file mode 100644 index 886693ff9469..000000000000 --- a/app/scripts/lib/local-store.js +++ /dev/null @@ -1,139 +0,0 @@ -import browser from 'webextension-polyfill'; -import log from 'loglevel'; -import { captureException } from '@sentry/browser'; -import { checkForLastError } from '../../../shared/modules/browser-runtime.utils'; - -/** - * A wrapper around the extension's storage local API - */ -export default class ExtensionStore { - constructor() { - this.isSupported = Boolean(browser.storage.local); - if (!this.isSupported) { - log.error('Storage local API not available.'); - } - // we use this flag to avoid flooding sentry with a ton of errors: - // once data persistence fails once and it flips true we don't send further - // data persistence errors to sentry - this.dataPersistenceFailing = false; - this.mostRecentRetrievedState = null; - this.isExtensionInitialized = false; - } - - setMetadata(initMetaData) { - this.metadata = initMetaData; - } - - async set(state) { - if (!this.isSupported) { - throw new Error( - 'Metamask- cannot persist state to local store as this browser does not support this action', - ); - } - if (!state) { - throw new Error('MetaMask - updated state is missing'); - } - if (!this.metadata) { - throw new Error( - 'MetaMask - metadata must be set on instance of ExtensionStore before calling "set"', - ); - } - try { - // we format the data for storage as an object with the "data" key for the controller state object - // and the "meta" key for a metadata object containing a version number that tracks how the data shape - // has changed using migrations to adapt to backwards incompatible changes - await this._set({ data: state, meta: this.metadata }); - if (this.dataPersistenceFailing) { - this.dataPersistenceFailing = false; - } - } catch (err) { - if (!this.dataPersistenceFailing) { - this.dataPersistenceFailing = true; - captureException(err); - } - log.error('error setting state in local store:', err); - } finally { - this.isExtensionInitialized = true; - } - } - - /** - * Returns all of the keys currently saved - * - * @returns {Promise<*>} - */ - async get() { - if (!this.isSupported) { - return undefined; - } - - const result = await this._get(); - // extension.storage.local always returns an obj - // if the object is empty, treat it as undefined - if (isEmpty(result)) { - this.mostRecentRetrievedState = null; - return undefined; - } - if (!this.isExtensionInitialized) { - this.mostRecentRetrievedState = result; - } - return result; - } - - /** - * Returns all of the keys currently saved - * - * @private - * @returns {object} the key-value map from local storage - */ - _get() { - const { local } = browser.storage; - return new Promise((resolve, reject) => { - local.get(null).then((/** @type {any} */ result) => { - const err = checkForLastError(); - if (err) { - reject(err); - } else { - resolve(result); - } - }); - }); - } - - /** - * Sets the key in local state - * - * @param {object} obj - The key to set - * @returns {Promise} - * @private - */ - _set(obj) { - const { local } = browser.storage; - return new Promise((resolve, reject) => { - local.set(obj).then(() => { - const err = checkForLastError(); - if (err) { - reject(err); - } else { - resolve(); - } - }); - }); - } - - cleanUpMostRecentRetrievedState() { - if (this.mostRecentRetrievedState) { - this.mostRecentRetrievedState = null; - } - } -} - -/** - * Returns whether or not the given object contains no keys - * - * @param {object} obj - The object to check - * @returns {boolean} - */ -function isEmpty(obj) { - return Object.keys(obj).length === 0; -} diff --git a/app/scripts/lib/setup-initial-state-hooks.js b/app/scripts/lib/setup-initial-state-hooks.js index d0b689c9cb30..e4aea8e19153 100644 --- a/app/scripts/lib/setup-initial-state-hooks.js +++ b/app/scripts/lib/setup-initial-state-hooks.js @@ -1,8 +1,8 @@ import { maskObject } from '../../../shared/modules/object.utils'; import ExtensionPlatform from '../platforms/extension'; import { SENTRY_BACKGROUND_STATE } from '../constants/sentry-state'; -import LocalStore from './local-store'; -import ReadOnlyNetworkStore from './network-store'; +import LocalStore from './state-management/local-store'; +import ReadOnlyNetworkStore from './state-management/network-store'; const platform = new ExtensionPlatform(); diff --git a/app/scripts/lib/local-store.test.js b/app/scripts/lib/state-management/local-store.test.js similarity index 60% rename from app/scripts/lib/local-store.test.js rename to app/scripts/lib/state-management/local-store.test.js index 34289185de79..e4c2b5ccc5c5 100644 --- a/app/scripts/lib/local-store.test.js +++ b/app/scripts/lib/state-management/local-store.test.js @@ -1,38 +1,76 @@ -import browser from 'webextension-polyfill'; import LocalStore from './local-store'; -jest.mock('webextension-polyfill', () => ({ - runtime: { lastError: null }, - storage: { local: true }, -})); - -const setup = ({ localMock = jest.fn() } = {}) => { - browser.storage.local = localMock; - return new LocalStore(); +const mockIDBRequest = (result, isError) => { + const request = { + onsuccess: null, + onerror: null, + result, + }; + + // Delay execution to simulate async behavior of IDBRequest + setTimeout(() => { + if (isError) { + if (typeof request.onerror === 'function') { + request.onerror({ target: { error: 'Mock error' } }); + } + } else if (typeof request.onsuccess === 'function') { + request.onsuccess({ target: request }); + } + }, 0); + + return request; }; + +const createEmptySetup = () => + (global.indexedDB = { + open: jest.fn(() => + mockIDBRequest({ + transaction: jest.fn(() => ({ + objectStore: jest.fn(() => ({ + get: jest.fn(() => mockIDBRequest({})), + put: jest.fn(() => mockIDBRequest({})), + })), + })), + }), + ), + }); + describe('LocalStore', () => { + let setup; + beforeEach(() => { + setup = () => { + // Mock the indexedDB open function + global.indexedDB = { + open: jest.fn(() => + mockIDBRequest({ + transaction: jest.fn(() => ({ + objectStore: jest.fn(() => ({ + get: jest.fn(() => + mockIDBRequest({ appState: { test: true } }), + ), + put: jest.fn(() => mockIDBRequest({})), + })), + })), + }), + ), + }; + return new LocalStore(); + }; + }); + afterEach(() => { jest.resetModules(); + jest.clearAllMocks(); }); - describe('contructor', () => { - it('should set isSupported property to false when browser does not support local storage', () => { - const localStore = setup({ localMock: false }); - - expect(localStore.isSupported).toBe(false); - }); - - it('should set isSupported property to true when browser supports local storage', () => { - const localStore = setup(); - expect(localStore.isSupported).toBe(true); - }); + describe('constructor', () => { it('should initialize mostRecentRetrievedState to null', () => { - const localStore = setup({ localMock: false }); + const localStore = setup(); expect(localStore.mostRecentRetrievedState).toBeNull(); }); it('should initialize isExtensionInitialized to false', () => { - const localStore = setup({ localMock: false }); + const localStore = setup(); expect(localStore.isExtensionInitialized).toBeFalsy(); }); }); @@ -48,13 +86,6 @@ describe('LocalStore', () => { }); describe('set', () => { - it('should throw an error if called in a browser that does not support local storage', async () => { - const localStore = setup({ localMock: false }); - await expect(() => localStore.set()).rejects.toThrow( - 'Metamask- cannot persist state to local store as this browser does not support this action', - ); - }); - it('should throw an error if not passed a truthy value as an argument', async () => { const localStore = setup(); await expect(() => localStore.set()).rejects.toThrow( @@ -74,8 +105,8 @@ describe('LocalStore', () => { it('should not throw if passed a valid argument and metadata has been set', async () => { const localStore = setup(); localStore.setMetadata({ version: 74 }); - await expect(async function () { - localStore.set({ appState: { test: true } }); + await expect(async () => { + await localStore.set({ appState: { test: true } }); }).not.toThrow(); }); @@ -88,22 +119,19 @@ describe('LocalStore', () => { }); describe('get', () => { - it('should return undefined if called in a browser that does not support local storage', async () => { - const localStore = setup({ localMock: false }); + it('should return undefined if no state is stored', async () => { + setup = () => { + createEmptySetup(); + return new LocalStore(); + }; + + const localStore = setup(); const result = await localStore.get(); expect(result).toStrictEqual(undefined); }); it('should update mostRecentRetrievedState', async () => { - const localStore = setup({ - localMock: { - get: jest - .fn() - .mockImplementation(() => - Promise.resolve({ appState: { test: true } }), - ), - }, - }); + const localStore = setup(); await localStore.get(); @@ -112,24 +140,19 @@ describe('LocalStore', () => { }); }); - it('should reset mostRecentRetrievedState to null if storage.local is empty', async () => { - const localStore = setup({ - localMock: { - get: jest.fn().mockImplementation(() => Promise.resolve({})), - }, - }); + it('should reset mostRecentRetrievedState to null if storage is empty', async () => { + setup = () => { + createEmptySetup(); + return new LocalStore(); + }; + const localStore = setup(); await localStore.get(); - expect(localStore.mostRecentRetrievedState).toStrictEqual(null); }); it('should set mostRecentRetrievedState to current state if isExtensionInitialized is true', async () => { - const localStore = setup({ - localMock: { - get: jest.fn().mockImplementation(() => Promise.resolve({})), - }, - }); + const localStore = setup(); localStore.setMetadata({ version: 74 }); await localStore.set({ appState: { test: true } }); await localStore.get(); @@ -139,25 +162,14 @@ describe('LocalStore', () => { describe('cleanUpMostRecentRetrievedState', () => { it('should set mostRecentRetrievedState to null if it is defined', async () => { - const localStore = setup({ - localMock: { - get: jest - .fn() - .mockImplementation(() => - Promise.resolve({ appState: { test: true } }), - ), - }, - }); + const localStore = setup(); await localStore.get(); - - // mostRecentRetrievedState should be { appState: { test: true } } at this stage await localStore.cleanUpMostRecentRetrievedState(); expect(localStore.mostRecentRetrievedState).toStrictEqual(null); }); it('should not set mostRecentRetrievedState if it is null', async () => { const localStore = setup(); - expect(localStore.mostRecentRetrievedState).toStrictEqual(null); await localStore.cleanUpMostRecentRetrievedState(); expect(localStore.mostRecentRetrievedState).toStrictEqual(null); diff --git a/app/scripts/lib/state-management/local-store.ts b/app/scripts/lib/state-management/local-store.ts new file mode 100644 index 000000000000..6d3944831ffd --- /dev/null +++ b/app/scripts/lib/state-management/local-store.ts @@ -0,0 +1,251 @@ +/** + * @file ExtensionStore.ts + * + * This file contains the `ExtensionStore` class, which provides a wrapper around the + * IndexedDB API to manage the persistence of MetaMask extension state. The class is + * responsible for initializing the IndexedDB store, saving the state along with its + * metadata, and retrieving the most recent state. + * + * The class handles errors during data persistence and ensures that the extension + * operates correctly even when the IndexedDB API fails. It also reports errors to + * Sentry for tracking persistence failures. + * + * Inspecting IndexedDB in Chrome DevTools: + * 1. Open Chrome DevTools (Right-click on the page > Inspect > Application tab). + * 2. Under the "Storage" section in the left sidebar, click on "IndexedDB". + * 3. Locate the `ExtensionStore` database and expand it to see the object stores. + * 4. Click on the `ExtensionStore` object store to view and inspect the saved state. + * + * Alternatively, you can inspect the state via the browser console using the following code: + * + * ```javascript + * indexedDB.open('ExtensionStore').onsuccess = function (event) { + * const db = event.target.result; + * const transaction = db.transaction('ExtensionStore', 'readonly'); + * const objectStore = transaction.objectStore('ExtensionStore'); + * objectStore.getAll().onsuccess = function (e) { + * console.log(e.target.result); + * }; + * }; + * ``` + * + * Usage: + * + * ```typescript + * const extensionStore = new ExtensionStore(); + * await extensionStore.set({ key: 'value' }); + * const state = await extensionStore.get(); + * ``` + */ + +import log from 'loglevel'; +import { captureException } from '@sentry/browser'; + +const STATE_KEY = 'metamaskState'; + +enum TransactionMode { + READ_ONLY = 'readonly', + READ_WRITE = 'readwrite', +} + +/** + * A wrapper around the extension's storage using IndexedDB API. + */ +export default class ExtensionStore { + private readonly storeName: string; + + private readonly dbVersion: number; + + private metadata: Record | null; + + private dataPersistenceFailing: boolean; + + private mostRecentRetrievedState: Record | null; + + private isExtensionInitialized: boolean; + + /** + * Creates an instance of the ExtensionStore. + * + * @param storeName - The name of the IndexedDB store. + * @param dbVersion - The version of the IndexedDB store. + */ + constructor(storeName = 'ExtensionStore', dbVersion = 1) { + this.storeName = storeName; + this.dbVersion = dbVersion; + this.dataPersistenceFailing = false; + this.mostRecentRetrievedState = null; + this.isExtensionInitialized = false; + this.metadata = null; + this._init(); + } + + /** + * Initializes the IndexedDB store and creates an object store if necessary. + * + * @private + */ + private _init() { + const request = indexedDB.open(this.storeName, this.dbVersion); + + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + if (!db.objectStoreNames.contains(this.storeName)) { + db.createObjectStore(this.storeName, { keyPath: 'id' }); + } + }; + + request.onerror = () => { + log.error('IndexedDB not supported or initialization failed.'); + }; + } + + /** + * Opens the IndexedDB store in the specified transaction mode. + * + * @param mode - The transaction mode (readonly or readwrite). + * @returns A promise that resolves to the object store. + * @private + */ + private _getObjectStore( + mode: IDBTransactionMode = TransactionMode.READ_ONLY, + ): Promise { + return new Promise((resolve, reject) => { + const request = indexedDB.open(this.storeName, this.dbVersion); + + request.onerror = () => { + reject(new Error('Failed to open IndexedDB.')); + }; + + request.onsuccess = (event) => { + const db = (event.target as IDBOpenDBRequest).result; + const transaction = db.transaction([this.storeName], mode); + const objectStore = transaction.objectStore(this.storeName); + resolve(objectStore); + }; + }); + } + + /** + * Sets metadata to be stored with the state. + * + * @param initMetaData - The metadata to store. + * @returns A promise that resolves when the metadata is set. + */ + async setMetadata(initMetaData: Record): Promise { + this.metadata = initMetaData; + } + + /** + * Saves the current state in IndexedDB. + * + * @param state - The state to be saved. + * @throws If the state or metadata is missing. + * @returns A promise that resolves when the state is saved. + */ + async set(state: Record): Promise { + if (!state) { + throw new Error('MetaMask - updated state is missing'); + } + if (!this.metadata) { + throw new Error( + 'MetaMask - metadata must be set on instance of ExtensionStore before calling "set"', + ); + } + + try { + const dataToStore = { id: STATE_KEY, data: state, meta: this.metadata }; + await this._writeToDB(dataToStore); + if (this.dataPersistenceFailing) { + this.dataPersistenceFailing = false; + } + } catch (err) { + if (!this.dataPersistenceFailing) { + this.dataPersistenceFailing = true; + captureException(err); + } + log.error('Error setting state in IndexedDB:', err); + } finally { + this.isExtensionInitialized = true; + } + } + + /** + * Retrieves the state from IndexedDB. + * + * @returns A promise that resolves to the stored state, or `undefined` if not found. + */ + async get(): Promise | undefined> { + try { + const result = await this._readFromDB(STATE_KEY); + if (!result || this.isEmpty(result)) { + this.mostRecentRetrievedState = null; + return undefined; + } + if (!this.isExtensionInitialized) { + this.mostRecentRetrievedState = result; + } + return result; + } catch (err) { + log.error('Error getting state from IndexedDB:', err); + return undefined; + } + } + + /** + * Writes data to IndexedDB. + * + * @param data - The data to write. + * @returns A promise that resolves when the data is written. + * @private + */ + private async _writeToDB(data: Record): Promise { + return new Promise((resolve, reject) => { + this._getObjectStore(TransactionMode.READ_WRITE) + .then((objectStore) => { + const request = objectStore.put(data); + request.onsuccess = () => resolve(); + request.onerror = () => reject(request.error); + }) + .catch(reject); + }); + } + + /** + * Reads data from IndexedDB. + * + * @param id - The key of the data to read. + * @returns A promise that resolves to the data read from the store. + * @private + */ + private async _readFromDB( + id: string, + ): Promise | null> { + return new Promise | null>((resolve, reject) => { + this._getObjectStore(TransactionMode.READ_ONLY) + .then((objectStore) => { + const request = objectStore.get(id); + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }) + .catch(reject); + }); + } + + /** + * Cleans up the most recent retrieved state. + */ + cleanUpMostRecentRetrievedState(): void { + this.mostRecentRetrievedState = null; + } + + /** + * Checks if an object is empty. + * + * @param obj - The object to check. + * @returns `true` if the object is empty, otherwise `false`. + */ + isEmpty(obj: object): boolean { + return Object.keys(obj).length === 0; + } +} diff --git a/app/scripts/lib/state-management/network-store.test.ts b/app/scripts/lib/state-management/network-store.test.ts new file mode 100644 index 000000000000..a3c774127a2a --- /dev/null +++ b/app/scripts/lib/state-management/network-store.test.ts @@ -0,0 +1,73 @@ +import nock from 'nock'; +import log from 'loglevel'; +import ReadOnlyNetworkStore from './network-store'; + +// The URL that will be used in the fetch call +const FIXTURE_SERVER_URL = 'http://localhost:12345'; +const mockState: Record = { key: 'value' }; +const mockMetadata: Record = { version: 1 }; + +describe('ReadOnlyNetworkStore', () => { + afterEach(() => { + nock.cleanAll(); + }); + + it('should fetch the state from the server and store it', async () => { + nock(FIXTURE_SERVER_URL).get('/state.json').reply(200, mockState); + const store = new ReadOnlyNetworkStore(); + await store.get(); + expect(store.mostRecentRetrievedState).toEqual(mockState); + }); + + it('should log an error if the server returns an error', async () => { + nock(FIXTURE_SERVER_URL).get('/state.json').replyWithError('Network error'); + const logSpy = jest.spyOn(log, 'debug').mockImplementation(() => { + return null; + }); + const store = new ReadOnlyNetworkStore(); + await store.get(); + expect(logSpy).toHaveBeenCalledWith( + "Error loading network state: 'request to http://localhost:12345/state.json failed, reason: Network error'", + ); + logSpy.mockRestore(); + }); + + it('should set metadata and retrieve it', () => { + const store = new ReadOnlyNetworkStore(); + store.setMetadata(mockMetadata); + expect(store.metadata).toEqual(mockMetadata); + }); + + it('should throw an error when setting state without metadata', async () => { + const store = new ReadOnlyNetworkStore(); + await expect(store.set(mockState)).rejects.toThrow( + 'MetaMask - metadata must be set on instance of ExtensionStore before calling "set"', + ); + }); + + it('should throw an error when setting an empty state', async () => { + const store = new ReadOnlyNetworkStore(); + store.setMetadata(mockMetadata); // Ensure metadata is set to bypass previous check + await expect( + store.set(undefined as unknown as Record), + ).rejects.toThrow('MetaMask - updated state is missing'); + }); + + it('should set the state when metadata is present and state is valid', async () => { + const store = new ReadOnlyNetworkStore(); + store.setMetadata(mockMetadata); + await store.set(mockState); + + expect(store._state).toEqual({ + data: mockState, + meta: mockMetadata, + }); + }); + + it('should clear the most recent retrieved state when cleanUpMostRecentRetrievedState is called', () => { + const store = new ReadOnlyNetworkStore(); + store.mostRecentRetrievedState = mockState; + store.cleanUpMostRecentRetrievedState(); + expect(store.mostRecentRetrievedState).toBeNull(); + }); +}); diff --git a/app/scripts/lib/network-store.js b/app/scripts/lib/state-management/network-store.ts similarity index 55% rename from app/scripts/lib/network-store.js rename to app/scripts/lib/state-management/network-store.ts index e167807c7d9d..4b58335ad91a 100644 --- a/app/scripts/lib/network-store.js +++ b/app/scripts/lib/state-management/network-store.ts @@ -1,5 +1,5 @@ import log from 'loglevel'; -import getFetchWithTimeout from '../../../shared/modules/fetch-with-timeout'; +import getFetchWithTimeout from '../../../../shared/modules/fetch-with-timeout'; const fetchWithTimeout = getFetchWithTimeout(); @@ -11,6 +11,16 @@ const FIXTURE_SERVER_URL = `http://${FIXTURE_SERVER_HOST}:${FIXTURE_SERVER_PORT} * A read-only network-based storage wrapper */ export default class ReadOnlyNetworkStore { + private _initialized: boolean; + + private _initializing: Promise; + + public _state: Record | undefined; + + public mostRecentRetrievedState: Record | null; + + public metadata?: object; + constructor() { this._initialized = false; this._initializing = this._init(); @@ -25,55 +35,60 @@ export default class ReadOnlyNetworkStore { /** * Initializes by loading state from the network + * + * @private */ - async _init() { + private async _init(): Promise { try { const response = await fetchWithTimeout(FIXTURE_SERVER_URL); if (response.ok) { this._state = await response.json(); } - } catch (error) { - log.debug(`Error loading network state: '${error.message}'`); + } catch (error: unknown) { + if (error instanceof Error) { + log.debug(`Error loading network state: '${error.message}'`); + } } finally { this._initialized = true; } } /** - * Returns state + * Returns the current state * - * @returns {Promise} + * @returns A promise that resolves to the current state. */ - async get() { + async get(): Promise | undefined> { if (!this._initialized) { await this._initializing; } - // Delay setting this until after the first read, to match the - // behavior of the local store. + + // Delay setting this until after the first read if (!this.mostRecentRetrievedState) { - this.mostRecentRetrievedState = this._state; + this.mostRecentRetrievedState = this._state ?? null; } + return this._state; } /** - * Set metadata/version state + * Sets metadata/version data * - * @param {object} metadata - The metadata/version data to set + * @param metadata - The metadata to set */ - setMetadata(metadata) { + setMetadata(metadata: object): void { this.metadata = metadata; } /** - * Set state + * Sets the state directly in memory * - * @param {object} state - The state to set + * @param state - The state to set */ - async set(state) { + async set(state: Record): Promise { if (!this.isSupported) { throw new Error( - 'Metamask- cannot persist state to local store as this browser does not support this action', + 'MetaMask - cannot persist state to local store as this browser does not support this action', ); } if (!state) { @@ -87,10 +102,14 @@ export default class ReadOnlyNetworkStore { if (!this._initialized) { await this._initializing; } - this._state = { data: state, meta: this._metadata }; + + this._state = { data: state, meta: this.metadata }; } - cleanUpMostRecentRetrievedState() { + /** + * Clears the most recent retrieved state + */ + cleanUpMostRecentRetrievedState(): void { if (this.mostRecentRetrievedState) { this.mostRecentRetrievedState = null; }