react-native-onyx 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ /**
2
+ * The AsyncStorage provider stores everything in a key/value store by
3
+ * converting the value to a JSON string
4
+ */
5
+
6
+ import _ from 'underscore';
7
+ import AsyncStorage from '@react-native-async-storage/async-storage';
8
+
9
+ const provider = {
10
+ /**
11
+ * Get the value of a given key or return `null` if it's not available in storage
12
+ * @param {String} key
13
+ * @return {Promise<*>}
14
+ */
15
+ getItem(key) {
16
+ return AsyncStorage.getItem(key)
17
+ .then((value) => {
18
+ const parsed = value && JSON.parse(value);
19
+ return parsed;
20
+ });
21
+ },
22
+
23
+ /**
24
+ * Get multiple key-value pairs for the give array of keys in a batch
25
+ * @param {String[]} keys
26
+ * @return {Promise<Array<[key, value]>>}
27
+ */
28
+ multiGet(keys) {
29
+ return AsyncStorage.multiGet(keys)
30
+ .then(pairs => _.map(pairs, ([key, value]) => [key, value && JSON.parse(value)]));
31
+ },
32
+
33
+ /**
34
+ * Sets the value for a given key. The only requirement is that the value should be serializable to JSON string
35
+ * @param {String} key
36
+ * @param {*} value
37
+ * @return {Promise<void>}
38
+ */
39
+ setItem(key, value) {
40
+ return AsyncStorage.setItem(key, JSON.stringify(value));
41
+ },
42
+
43
+ /**
44
+ * Stores multiple key-value pairs in a batch
45
+ * @param {Array<[key, value]>} pairs
46
+ * @return {Promise<void>}
47
+ */
48
+ multiSet(pairs) {
49
+ const stringPairs = _.map(pairs, ([key, value]) => [key, JSON.stringify(value)]);
50
+ return AsyncStorage.multiSet(stringPairs);
51
+ },
52
+
53
+ /**
54
+ * Multiple merging of existing and new values in a batch
55
+ * @param {Array<[key, value]>} pairs
56
+ * @return {Promise<void>}
57
+ */
58
+ multiMerge(pairs) {
59
+ const stringPairs = _.map(pairs, ([key, value]) => [key, JSON.stringify(value)]);
60
+ return AsyncStorage.multiMerge(stringPairs);
61
+ },
62
+
63
+ /**
64
+ * Returns all keys available in storage
65
+ * @returns {Promise<String[]>}
66
+ */
67
+ getAllKeys: AsyncStorage.getAllKeys,
68
+
69
+ /**
70
+ * Remove given key and it's value from storage
71
+ * @param {String} key
72
+ * @returns {Promise<void>}
73
+ */
74
+ removeItem: AsyncStorage.removeItem,
75
+
76
+ /**
77
+ * Clear absolutely everything from storage
78
+ * @returns {Promise<void>}
79
+ */
80
+ clear: AsyncStorage.clear,
81
+ };
82
+
83
+ export default provider;
@@ -0,0 +1,111 @@
1
+ /**
2
+ * @file
3
+ * The storage provider based on localforage allows us to store most anything in its
4
+ * natural form in the underlying DB without having to stringify or de-stringify it
5
+ */
6
+
7
+ import localforage from 'localforage';
8
+ import _ from 'underscore';
9
+ import lodashMerge from 'lodash/merge';
10
+ import SyncQueue from '../../SyncQueue';
11
+
12
+ localforage.config({
13
+ name: 'OnyxDB'
14
+ });
15
+
16
+ const provider = {
17
+ /**
18
+ * Writing very quickly to IndexedDB causes performance issues and can lock up the page and lead to jank.
19
+ * So, we are slowing this process down by waiting until one write is complete before moving on
20
+ * to the next.
21
+ */
22
+ setItemQueue: new SyncQueue(({key, value, shouldMerge}) => {
23
+ if (shouldMerge) {
24
+ return localforage.getItem(key)
25
+ .then((existingValue) => {
26
+ const newValue = _.isObject(existingValue)
27
+ ? lodashMerge({}, existingValue, value)
28
+ : value;
29
+ return localforage.setItem(key, newValue);
30
+ });
31
+ }
32
+
33
+ return localforage.setItem(key, value);
34
+ }),
35
+
36
+ /**
37
+ * Get multiple key-value pairs for the give array of keys in a batch
38
+ * @param {String[]} keys
39
+ * @return {Promise<Array<[key, value]>>}
40
+ */
41
+ multiGet(keys) {
42
+ const pairs = _.map(
43
+ keys,
44
+ key => localforage.getItem(key)
45
+ .then(value => [key, value])
46
+ );
47
+
48
+ return Promise.all(pairs);
49
+ },
50
+
51
+ /**
52
+ * Multiple merging of existing and new values in a batch
53
+ * @param {Array<[key, value]>} pairs
54
+ * @return {Promise<void>}
55
+ */
56
+ multiMerge(pairs) {
57
+ const tasks = _.map(pairs, ([key, value]) => this.setItemQueue.push({key, value, shouldMerge: true}));
58
+
59
+ // We're returning Promise.resolve, otherwise the array of task results will be returned to the caller
60
+ return Promise.all(tasks).then(() => Promise.resolve());
61
+ },
62
+
63
+ /**
64
+ * Stores multiple key-value pairs in a batch
65
+ * @param {Array<[key, value]>} pairs
66
+ * @return {Promise<void>}
67
+ */
68
+ multiSet(pairs) {
69
+ // We're returning Promise.resolve, otherwise the array of task results will be returned to the caller
70
+ const tasks = _.map(pairs, ([key, value]) => this.setItem(key, value));
71
+ return Promise.all(tasks).then(() => Promise.resolve());
72
+ },
73
+
74
+ /**
75
+ * Clear absolutely everything from storage
76
+ * @returns {Promise<void>}
77
+ */
78
+ clear: localforage.clear,
79
+
80
+ /**
81
+ * Returns all keys available in storage
82
+ * @returns {Promise<String[]>}
83
+ */
84
+ getAllKeys: localforage.keys,
85
+
86
+ /**
87
+ * Get the value of a given key or return `null` if it's not available in storage
88
+ * @param {String} key
89
+ * @return {Promise<*>}
90
+ */
91
+ getItem: localforage.getItem,
92
+
93
+ /**
94
+ * Remove given key and it's value from storage
95
+ * @param {String} key
96
+ * @returns {Promise<void>}
97
+ */
98
+ removeItem: localforage.removeItem,
99
+
100
+ /**
101
+ * Sets the value for a given key. The only requirement is that the value should be serializable to JSON string
102
+ * @param {String} key
103
+ * @param {*} value
104
+ * @return {Promise<void>}
105
+ */
106
+ setItem(key, value) {
107
+ return this.setItemQueue.push({key, value});
108
+ },
109
+ };
110
+
111
+ export default provider;
@@ -0,0 +1,200 @@
1
+ /**
2
+ * This is a higher order component that provides the ability to map a state property directly to
3
+ * something in Onyx (a key/value store). That way, as soon as data in Onyx changes, the state will be set and the view
4
+ * will automatically change to reflect the new data.
5
+ */
6
+ import React from 'react';
7
+ import _ from 'underscore';
8
+ import PropTypes from 'prop-types';
9
+ import Str from 'expensify-common/lib/str';
10
+ import Onyx from './Onyx';
11
+
12
+ /**
13
+ * Returns the display name of a component
14
+ *
15
+ * @param {object} component
16
+ * @returns {string}
17
+ */
18
+ function getDisplayName(component) {
19
+ return component.displayName || component.name || 'Component';
20
+ }
21
+
22
+ export default function (mapOnyxToState) {
23
+ // A list of keys that must be present in tempState before we can render the WrappedComponent
24
+ const requiredKeysForInit = _.chain(mapOnyxToState)
25
+ .omit(config => config.initWithStoredValues === false)
26
+ .keys()
27
+ .value();
28
+
29
+ return (WrappedComponent) => {
30
+ class withOnyx extends React.Component {
31
+ constructor(props) {
32
+ super(props);
33
+
34
+ this.setWithOnyxState = this.setWithOnyxState.bind(this);
35
+
36
+ // This stores all the Onyx connection IDs to be used when the component unmounts so everything can be
37
+ // disconnected. It is a key value store with the format {[mapping.key]: connectionID}.
38
+ this.activeConnectionIDs = {};
39
+
40
+ // Object holding the temporary initial state for the component while we load the various Onyx keys
41
+ this.tempState = {};
42
+
43
+ this.state = {
44
+ // If there are no required keys for init then we can render the wrapped component immediately
45
+ loading: requiredKeysForInit.length > 0,
46
+ };
47
+ }
48
+
49
+ componentDidMount() {
50
+ // Subscribe each of the state properties to the proper Onyx key
51
+ _.each(mapOnyxToState, (mapping, propertyName) => {
52
+ this.connectMappingToOnyx(mapping, propertyName);
53
+ });
54
+ this.checkEvictableKeys();
55
+ }
56
+
57
+ componentDidUpdate(prevProps) {
58
+ // If any of the mappings use data from the props, then when the props change, all the
59
+ // connections need to be reconnected with the new props
60
+ _.each(mapOnyxToState, (mapping, propertyName) => {
61
+ const previousKey = Str.result(mapping.key, prevProps);
62
+ const newKey = Str.result(mapping.key, this.props);
63
+
64
+ if (previousKey !== newKey) {
65
+ Onyx.disconnect(this.activeConnectionIDs[previousKey], previousKey);
66
+ delete this.activeConnectionIDs[previousKey];
67
+ this.connectMappingToOnyx(mapping, propertyName);
68
+ }
69
+ });
70
+ this.checkEvictableKeys();
71
+ }
72
+
73
+ componentWillUnmount() {
74
+ // Disconnect everything from Onyx
75
+ _.each(mapOnyxToState, (mapping) => {
76
+ const key = Str.result(mapping.key, this.props);
77
+ const connectionID = this.activeConnectionIDs[key];
78
+ Onyx.disconnect(connectionID, key);
79
+ });
80
+ }
81
+
82
+ /**
83
+ * This method is used externally by sendDataToConnection to prevent unnecessary renders while a component
84
+ * still in a loading state. The temporary initial state is saved to the component instance and setState()
85
+ * only called once all the necessary data has been collected.
86
+ *
87
+ * @param {String} statePropertyName
88
+ * @param {*} val
89
+ */
90
+ setWithOnyxState(statePropertyName, val) {
91
+ if (!this.state.loading) {
92
+ this.setState({[statePropertyName]: val});
93
+ return;
94
+ }
95
+
96
+ this.tempState[statePropertyName] = val;
97
+
98
+ // All state keys should exist and at least have a value of null
99
+ if (_.some(requiredKeysForInit, key => _.isUndefined(this.tempState[key]))) {
100
+ return;
101
+ }
102
+
103
+ this.setState({...this.tempState, loading: false});
104
+ delete this.tempState;
105
+ }
106
+
107
+ /**
108
+ * Makes sure each Onyx key we requested has been set to state with a value of some kind.
109
+ * We are doing this so that the wrapped component will only render when all the data
110
+ * it needs is available to it.
111
+ */
112
+ checkEvictableKeys() {
113
+ // We will add this key to our list of recently accessed keys
114
+ // if the canEvict function returns true. This is necessary criteria
115
+ // we MUST use to specify if a key can be removed or not.
116
+ _.each(mapOnyxToState, (mapping) => {
117
+ if (_.isUndefined(mapping.canEvict)) {
118
+ return;
119
+ }
120
+
121
+ const canEvict = Str.result(mapping.canEvict, this.props);
122
+ const key = Str.result(mapping.key, this.props);
123
+
124
+ if (!Onyx.isSafeEvictionKey(key)) {
125
+ // eslint-disable-next-line max-len
126
+ throw new Error(`canEvict cannot be used on key '${key}'. This key must explicitly be flagged as safe for removal by adding it to Onyx.init({safeEvictionKeys: []}).`);
127
+ }
128
+
129
+ if (canEvict) {
130
+ Onyx.removeFromEvictionBlockList(key, mapping.connectionID);
131
+ } else {
132
+ Onyx.addToEvictionBlockList(key, mapping.connectionID);
133
+ }
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Takes a single mapping and binds the state of the component to the store
139
+ *
140
+ * @param {object} mapping
141
+ * @param {string|function} mapping.key key to connect to. can be a string or a
142
+ * function that takes this.props as an argument and returns a string
143
+ * @param {string} statePropertyName the name of the state property that Onyx will add the data to
144
+ * @param {boolean} [mapping.initWithStoredValues] If set to false, then no data will be prefilled into the
145
+ * component
146
+ */
147
+ connectMappingToOnyx(mapping, statePropertyName) {
148
+ const key = Str.result(mapping.key, this.props);
149
+
150
+ this.activeConnectionIDs[key] = Onyx.connect({
151
+ ...mapping,
152
+ key,
153
+ statePropertyName,
154
+ withOnyxInstance: this,
155
+ });
156
+ }
157
+
158
+ render() {
159
+ if (this.state.loading) {
160
+ return null;
161
+ }
162
+
163
+ // Remove any internal state properties used by withOnyx
164
+ // that should not be passed to a wrapped component
165
+ let stateToPass = _.omit(this.state, 'loading');
166
+ stateToPass = _.omit(stateToPass, value => _.isNull(value));
167
+
168
+ // Remove any null values so that React replaces them with default props
169
+ const propsToPass = _.omit(this.props, value => _.isNull(value));
170
+
171
+ // Spreading props and state is necessary in an HOC where the data cannot be predicted
172
+ return (
173
+ <WrappedComponent
174
+ // eslint-disable-next-line react/jsx-props-no-spreading
175
+ {...propsToPass}
176
+ // eslint-disable-next-line react/jsx-props-no-spreading
177
+ {...stateToPass}
178
+ ref={this.props.forwardedRef}
179
+ />
180
+ );
181
+ }
182
+ }
183
+
184
+ withOnyx.propTypes = {
185
+ forwardedRef: PropTypes.oneOfType([
186
+ PropTypes.func,
187
+ PropTypes.shape({current: PropTypes.instanceOf(React.Component)}),
188
+ ]),
189
+ };
190
+ withOnyx.defaultProps = {
191
+ forwardedRef: undefined,
192
+ };
193
+ withOnyx.displayName = `withOnyx(${getDisplayName(WrappedComponent)})`;
194
+ return React.forwardRef((props, ref) => {
195
+ const Component = withOnyx;
196
+ // eslint-disable-next-line react/jsx-props-no-spreading
197
+ return <Component {...props} forwardedRef={ref} />;
198
+ });
199
+ };
200
+ }
package/native.js ADDED
@@ -0,0 +1,11 @@
1
+ /**
2
+ * @file
3
+ * Native entry point for Onyx
4
+ * This file is resolved by react-native projects
5
+ */
6
+ import Onyx from './lib';
7
+
8
+ // We resolve pure source for react-native projects and let `metro` bundle it
9
+ // We can test small changes directly from the parent project `node_modules/react-native-onyx` source
10
+ export * from './lib';
11
+ export default Onyx;
package/package.json ADDED
@@ -0,0 +1,109 @@
1
+ {
2
+ "name": "react-native-onyx",
3
+ "version": "1.0.1",
4
+ "author": "Expensify, Inc.",
5
+ "homepage": "https://expensify.com",
6
+ "description": "State management for React Native",
7
+ "license": "MIT",
8
+ "private": false,
9
+ "files": [
10
+ "dist/**/*",
11
+ "lib/**/*",
12
+ "native.js",
13
+ "web.js",
14
+ "API.md",
15
+ "README.md",
16
+ "LICENSE.md"
17
+ ],
18
+ "react-native": "native.js",
19
+ "main": "native.js",
20
+ "browser": "web.js",
21
+ "scripts": {
22
+ "lint": "eslint .",
23
+ "lint-tests": "eslint tests/**",
24
+ "test": "jest",
25
+ "build:web": "webpack --config webpack.config.js",
26
+ "build:docs": "node buildDocs.js"
27
+ },
28
+ "dependencies": {
29
+ "ascii-table": "0.0.9",
30
+ "lodash": "^4.17.21",
31
+ "underscore": "^1.13.1"
32
+ },
33
+ "devDependencies": {
34
+ "@babel/core": "^7.17.10",
35
+ "@babel/plugin-proposal-class-properties": "^7.16.7",
36
+ "@babel/runtime": "^7.11.2",
37
+ "@react-native-async-storage/async-storage": "^1.15.5",
38
+ "@react-native-community/eslint-config": "^2.0.0",
39
+ "@testing-library/jest-native": "^3.4.2",
40
+ "@testing-library/react-native": "^7.0.2",
41
+ "babel-eslint": "^10.1.0",
42
+ "babel-jest": "^26.2.2",
43
+ "babel-loader": "^8.2.5",
44
+ "babel-plugin-module-resolver": "^4.0.0",
45
+ "babel-plugin-react-native-web": "^0.13.5",
46
+ "babel-plugin-transform-class-properties": "^6.24.1",
47
+ "eslint": "^7.6.0",
48
+ "eslint-config-expensify": "^2.0.11",
49
+ "expensify-common": "git+https://github.com/Expensify/expensify-common.git#427295da130a4eacc184d38693664280d020dffd",
50
+ "jest": "^26.5.2",
51
+ "jest-cli": "^26.5.2",
52
+ "jsdoc-to-markdown": "^7.1.0",
53
+ "localforage": "^1.10.0",
54
+ "metro-react-native-babel-preset": "^0.61.0",
55
+ "prop-types": "^15.7.2",
56
+ "react": "^17.0.2",
57
+ "react-native": "0.64.1",
58
+ "react-native-performance": "^2.0.0",
59
+ "react-test-renderer": "16.13.1",
60
+ "webpack": "^5.72.1",
61
+ "webpack-cli": "^4.9.2",
62
+ "webpack-merge": "^5.8.0"
63
+ },
64
+ "peerDependencies": {
65
+ "@react-native-async-storage/async-storage": "^1.15.5",
66
+ "expensify-common": ">=1",
67
+ "localforage": "^1.10.0",
68
+ "react": ">=17.0.2",
69
+ "react-native-performance": "^2.0.0"
70
+ },
71
+ "peerDependenciesMeta": {
72
+ "react-native-performance": {
73
+ "optional": true
74
+ },
75
+ "@react-native-async-storage/async-storage": {
76
+ "optional": true
77
+ },
78
+ "localforage": {
79
+ "optional": true
80
+ }
81
+ },
82
+ "jest": {
83
+ "preset": "react-native",
84
+ "transform": {
85
+ "^.+\\.jsx?$": "babel-jest"
86
+ },
87
+ "transformIgnorePatterns": [
88
+ "node_modules/(?!react-native)/"
89
+ ],
90
+ "testPathIgnorePatterns": [
91
+ "<rootDir>/node_modules/",
92
+ "<rootDir>/tests/unit/mocks/"
93
+ ],
94
+ "testMatch": [
95
+ "**/tests/unit/**/*.[jt]s?(x)",
96
+ "**/?(*.)+(spec|test).[jt]s?(x)"
97
+ ],
98
+ "globals": {
99
+ "__DEV__": true,
100
+ "WebSocket": {}
101
+ },
102
+ "timers": "fake",
103
+ "testEnvironment": "jsdom",
104
+ "setupFilesAfterEnv": [
105
+ "@testing-library/jest-native/extend-expect"
106
+ ]
107
+ },
108
+ "sideEffects": false
109
+ }
package/web.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * @file
3
+ * Web entry point for Onyx
4
+ * This file is resolved by non react-native projects
5
+ * Like React for web or pure JS
6
+ */
7
+
8
+ if (process.env.NODE_ENV === 'production') {
9
+ module.exports = require('./dist/web.min.js');
10
+ } else {
11
+ module.exports = require('./dist/web.development.js');
12
+ }