diff --git a/desktop/flipper-plugin/src/state/__tests__/atom.node.tsx b/desktop/flipper-plugin/src/state/__tests__/atom.node.tsx new file mode 100644 index 000000000..d96fa0acc --- /dev/null +++ b/desktop/flipper-plugin/src/state/__tests__/atom.node.tsx @@ -0,0 +1,71 @@ +/** + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + * @format + */ + +import {createState} from '../atom'; + +test('can subscribe to atom state changes', () => { + const state = createState('abc'); + let receivedValue: string | undefined; + let receivedPrevValue: string | undefined; + const unsubscribe = state.subscribe((value, prevValue) => { + receivedValue = value; + receivedPrevValue = prevValue; + }); + try { + state.set('def'); + expect(receivedValue).toBe('def'); + expect(receivedPrevValue).toBe('abc'); + state.set('ghi'); + expect(receivedValue).toBe('ghi'); + expect(receivedPrevValue).toBe('def'); + } finally { + unsubscribe(); + } +}); + +test('can unsubscribe from atom state changes', () => { + const state = createState('abc'); + let receivedValue: string | undefined; + let receivedPrevValue: string | undefined; + const unsubscribe = state.subscribe((value, prevValue) => { + receivedValue = value; + receivedPrevValue = prevValue; + }); + try { + state.set('def'); + expect(receivedValue).toBe('def'); + expect(receivedPrevValue).toBe('abc'); + } finally { + unsubscribe(); + } + state.set('ghi'); + expect(receivedValue).toBe('def'); + expect(receivedPrevValue).toBe('abc'); +}); + +test('can unsubscribe from atom state changes using unsubscribe method', () => { + const state = createState('abc'); + let receivedValue: string | undefined; + let receivedPrevValue: string | undefined; + const listener = (value: string, prevValue: string) => { + receivedValue = value; + receivedPrevValue = prevValue; + }; + state.subscribe(listener); + try { + state.set('def'); + expect(receivedValue).toBe('def'); + expect(receivedPrevValue).toBe('abc'); + } finally { + state.unsubscribe(listener); + } + state.set('ghi'); + expect(receivedValue).toBe('def'); + expect(receivedPrevValue).toBe('abc'); +}); diff --git a/desktop/flipper-plugin/src/state/atom.tsx b/desktop/flipper-plugin/src/state/atom.tsx index 5efed5474..ed5cafaae 100644 --- a/desktop/flipper-plugin/src/state/atom.tsx +++ b/desktop/flipper-plugin/src/state/atom.tsx @@ -17,11 +17,13 @@ export type Atom = { get(): T; set(newValue: T): void; update(recipe: (draft: Draft) => void): void; + subscribe(listener: (value: T, prevValue: T) => void): () => void; + unsubscribe(listener: (value: T, prevValue: T) => void): void; }; class AtomValue implements Atom, Persistable { value: T; - listeners: ((value: T) => void)[] = []; + listeners: ((value: T, prevValue: T) => void)[] = []; constructor(initialValue: T) { this.value = initialValue; @@ -33,8 +35,9 @@ class AtomValue implements Atom, Persistable { set(nextValue: T) { if (nextValue !== this.value) { + const prevValue = this.value; this.value = nextValue; - this.notifyChanged(); + this.notifyChanged(prevValue); } } @@ -50,16 +53,17 @@ class AtomValue implements Atom, Persistable { this.set(produce(this.value, recipe)); } - notifyChanged() { + notifyChanged(prevValue: T) { // TODO: add scheduling - this.listeners.slice().forEach((l) => l(this.value)); + this.listeners.slice().forEach((l) => l(this.value, prevValue)); } - subscribe(listener: (value: T) => void) { + subscribe(listener: (value: T, prevValue: T) => void) { this.listeners.push(listener); + return () => this.unsubscribe(listener); } - unsubscribe(listener: (value: T) => void) { + unsubscribe(listener: (value: T, prevValue: T) => void) { const idx = this.listeners.indexOf(listener); if (idx !== -1) { this.listeners.splice(idx, 1); diff --git a/docs/extending/flipper-plugin.mdx b/docs/extending/flipper-plugin.mdx index f6f06c716..be9258d08 100644 --- a/docs/extending/flipper-plugin.mdx +++ b/docs/extending/flipper-plugin.mdx @@ -463,6 +463,8 @@ A state atom object is returned by `createState`, exposing the following methods * `get(): T`: Returns the current value stored. If you want to use the atom object in a React component, consider using the `useValue` hook instead, to make sure the component is notified about future updates of this atom. * `set(newValue: T)`: Stores a new value into the atom. If the new value is not reference-equal to the previous one, all observing components will be notified. * `update(updater: (draft: Draft) => void)`: Updates the current state using an [Immer](https://immerjs.github.io/immer/docs/introduction) recipe. In the `updater`, the `draft` object can be safely (deeply) mutated. Once the `updater` finishes, Immer will compute a new immutable object based on the changes, and store that. This is often simpler than using a combination of `get` and `set` if deep updates need to be made to the stored object. +* `subscribe(listener: (value: T, prevValue: T) => void): () => void`: Subscribes a listener function to the state updates. Listener function will receive the next and previous value on each update. The method also returns function which can be called to unsubscribe the listener from further updates. +* `unsubscribe(listener: (value: T, prevValue: T) => void): void`: Unsubscribes a listener function from the state updates if it was subscribed before. #### Example @@ -472,9 +474,14 @@ import {createState} from 'flipper-plugin' const rows = createState([], {persist: 'rows'}); const selectedID = createState(null, {persist: 'selection'}); -rows.set(["hello"]) +// Listener will be called on each rows.set() and rows.update() call until unsubscribed. +const unsubscribe = rows.subscribe((value, prevValue) => { + console.log(`Rows state updated. New length: ${value.length}. Prev length: ${prevValue.length}.`); +}); +rows.set(["hello"]) // Listener will be notified about the change console.log(rows.get().length) // 1 -rows.update(draft => { +unsubscribe(); // Do not notify listener anymore +rows.update(draft => { // Listener won't be notified about the change draft.push("world") }) console.log(rows.get().length) // 2