Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add initialValue, delayUpdates to allow components to eagerly render. #329

Merged
merged 17 commits into from
Sep 19, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions API.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ Subscribes a react component's state directly to a store key
| [mapping.initWithStoredValues] | <code>Boolean</code> | If set to false, then no data will be prefilled into the component |
| [mapping.waitForCollectionCallback] | <code>Boolean</code> | If set to true, it will return the entire collection to the callback as a single object |
| [mapping.selector] | <code>String</code> \| <code>function</code> | THIS PARAM IS ONLY USED WITH withOnyx(). If included, this will be used to subscribe to a subset of an Onyx key's data. If the selector is a string, the selector is passed to lodashGet on the sourceData. If the selector is a function, the sourceData and withOnyx state are passed to the selector and should return the simplified data. Using this setting on `withOnyx` can have very positive performance benefits because the component will only re-render when the subset of data changes. Otherwise, any change of data on any property would normally cause the component to re-render (and that can be expensive from a performance standpoint). |
| [mapping.initialValue] | <code>Any</code> | THIS PARAM IS ONLY USED WITH withOnyx(). If included, this will be passed as the initial hydrated value for the mapping. It will allow the component to render eagerly while data is being fetched from the DB. Note that it will not cause the component to have the `loading` prop set to true. |
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it expected that only initialValue is added to this API doc? It seems like shouldDelayUpdates and markReadyForHydration should show up somewhere here. Maybe try re-running the generator?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, this document is generated? I modified manually

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-ran the generator. However shouldDelayUpdates and markReadyForHidration are part of the HOC and not of Onyx, so not sure if I should put them here. That's why I also updated the README

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, OK... I guess that's fine if the generator didn't pick them up. I didn't think about it only being a part of the HOC. I think the stuff you added in the README is good enough for the HOC.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the initial hydrated value for the mapping

What exactly does "hydrated" mean? 😅

Is there a better term we can use for this that has the same meaning? I can only think of someone drinking a lot of water.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Standard React lingo. But open to suggestions.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Heh, yeah, but what does it mean to you? Like, let's say you were explaining this to a 7-year-old.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I was talking with another co-worker about this and ChatGPT for the win!

Screenshot_2023-09-15_at_10 44 15_AM-2023-09-15 17_46_10 411

So, I think it would be helpful to change this to say:

If included, this will be passed to the component so that something can be rendered while data is being fetched from the DB. Note that it will not cause the component to have the loading prop set to true.


**Example**
```js
Expand Down
30 changes: 29 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -135,7 +135,35 @@ export default withOnyx({
})(App);
```

It is preferable to use the HOC over `Onyx.connect()` in React code as `withOnyx()` will delay the rendering of the wrapped component until all keys have been accessed and made available.
It is preferable to use the HOC over `Onyx.connect()` in React code as `withOnyx()` will delay the rendering of the wrapped component until all keys/entities have been fetched and passed to the component. This however, can really delay your application if many entities are connected to the same component, you can pass an `initialValue` to each key to allow Onyx to eagerly render your component with this value.
marcaaron marked this conversation as resolved.
Show resolved Hide resolved

```javascript
export default withOnyx({
session: {
key: ONYXKEYS.SESSION,
initialValue: {}
},
})(App);
```

Additionally, if your component has many keys/entities you might also trigger frequent re-renders on the initial mounting. You can workaround this by passing an additional object with the `delayUpdates` property set to true. Onyx will then put all the updates in a queue until you decide when then should be applied, the component will receive a function `markReadyForHydration`. A good place to call this function is on the `onLayout` method, which gets triggered after your component has been rendered.
marcaaron marked this conversation as resolved.
Show resolved Hide resolved

```javascript
const App = ({session, markReadyForHydration}) => (
<View onLayout={() => markReadyForHydration()}>
{session.token ? <Text>Logged in</Text> : <Text>Logged out</Text> }
</View>
);

export default withOnyx({
session: {
key: ONYXKEYS.SESSION,
initialValue: {}
},
}, {
delayUpdates: true
})(App);
```

## Collections

Expand Down
28 changes: 17 additions & 11 deletions lib/Onyx.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ import * as Logger from './Logger';
import cache from './OnyxCache';
import * as Str from './Str';
import createDeferredTask from './createDeferredTask';
import fastMerge from './fastMerge';
import * as PerformanceUtils from './metrics/PerformanceUtils';
import Storage from './storage';
import DevTools from './DevTools';
import utils from './utils';

// Method constants
const METHOD = {
Expand Down Expand Up @@ -408,7 +408,7 @@ function keysChanged(collectionKey, partialCollection) {
// If the subscriber has a selector, then the component's state must only be updated with the data
// returned by the selector.
if (subscriber.selector) {
subscriber.withOnyxInstance.setState((prevState) => {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const previousData = prevState[subscriber.statePropertyName];
const newData = reduceCollectionWithSelector(cachedCollection, subscriber.selector, subscriber.withOnyxInstance.state);

Expand All @@ -422,7 +422,7 @@ function keysChanged(collectionKey, partialCollection) {
continue;
}

subscriber.withOnyxInstance.setState((prevState) => {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const finalCollection = _.clone(prevState[subscriber.statePropertyName] || {});
const dataKeys = _.keys(partialCollection);
for (let j = 0; j < dataKeys.length; j++) {
Expand Down Expand Up @@ -451,7 +451,7 @@ function keysChanged(collectionKey, partialCollection) {
// returned by the selector and the state should only change when the subset of data changes from what
// it was previously.
if (subscriber.selector) {
subscriber.withOnyxInstance.setState((prevState) => {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const prevData = prevState[subscriber.statePropertyName];
const newData = getSubsetOfData(cachedCollection[subscriber.key], subscriber.selector, subscriber.withOnyxInstance.state);
if (!deepEqual(prevData, newData)) {
Expand All @@ -466,9 +466,12 @@ function keysChanged(collectionKey, partialCollection) {
continue;
}

subscriber.withOnyxInstance.setState((prevState) => {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const data = cachedCollection[subscriber.key];
const previousData = prevState[subscriber.statePropertyName];
if (utils.areObjectsEmpty(data, previousData)) {
marcaaron marked this conversation as resolved.
Show resolved Hide resolved
return null;
}
if (data === previousData) {
return null;
}
Expand Down Expand Up @@ -532,7 +535,7 @@ function keyChanged(key, data, canUpdateSubscriber) {
// If the subscriber has a selector, then the consumer of this data must only be given the data
// returned by the selector and only when the selected data has changed.
if (subscriber.selector) {
subscriber.withOnyxInstance.setState((prevState) => {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const prevData = prevState[subscriber.statePropertyName];
const newData = {
[key]: getSubsetOfData(data, subscriber.selector, subscriber.withOnyxInstance.state),
Expand All @@ -552,7 +555,7 @@ function keyChanged(key, data, canUpdateSubscriber) {
continue;
}

subscriber.withOnyxInstance.setState((prevState) => {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const collection = prevState[subscriber.statePropertyName] || {};
const newCollection = {
...collection,
Expand All @@ -569,7 +572,7 @@ function keyChanged(key, data, canUpdateSubscriber) {
// If the subscriber has a selector, then the component's state must only be updated with the data
// returned by the selector and only if the selected data has changed.
if (subscriber.selector) {
subscriber.withOnyxInstance.setState((prevState) => {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const previousValue = getSubsetOfData(prevState[subscriber.statePropertyName], subscriber.selector, subscriber.withOnyxInstance.state);
const newValue = getSubsetOfData(data, subscriber.selector, subscriber.withOnyxInstance.state);
if (!deepEqual(previousValue, newValue)) {
Expand All @@ -583,8 +586,11 @@ function keyChanged(key, data, canUpdateSubscriber) {
}

// If we did not match on a collection key then we just set the new data to the state property
subscriber.withOnyxInstance.setState((prevState) => {
subscriber.withOnyxInstance.setStateProxy((prevState) => {
const previousData = prevState[subscriber.statePropertyName];
if (utils.areObjectsEmpty(data, previousData)) {
return null;
}
if (previousData === data) {
return null;
}
Expand Down Expand Up @@ -1048,7 +1054,7 @@ function applyMerge(existingValue, changes) {
// Object values are merged one after the other
// lodash adds a small overhead so we don't use it here
// eslint-disable-next-line prefer-object-spread, rulesdir/prefer-underscore-method
return _.reduce(changes, (modifiedData, change) => fastMerge(modifiedData, change),
return _.reduce(changes, (modifiedData, change) => utils.fastMerge(modifiedData, change),
existingValue || {});
}

Expand Down Expand Up @@ -1140,7 +1146,7 @@ function initializeWithDefaultKeyStates(enableDevTools = false) {
.then((pairs) => {
const asObject = _.object(pairs);

const merged = fastMerge(asObject, defaultKeyStates);
const merged = utils.fastMerge(asObject, defaultKeyStates);
cache.merge(merged);
_.each(merged, (val, key) => keyChanged(key, val));
if (enableDevTools) {
Expand Down
4 changes: 2 additions & 2 deletions lib/OnyxCache.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import _ from 'underscore';
import {deepEqual} from 'fast-equals';
import fastMerge from './fastMerge';
import utils from './utils';

const isDefined = _.negate(_.isUndefined);

Expand Down Expand Up @@ -119,7 +119,7 @@ class OnyxCache {

// lodash adds a small overhead so we don't use it here
// eslint-disable-next-line prefer-object-spread, rulesdir/prefer-underscore-method
this.storageMap = Object.assign({}, fastMerge(this.storageMap, data));
this.storageMap = Object.assign({}, utils.fastMerge(this.storageMap, data));

const storageKeys = this.getAllKeys();
const mergedKeys = _.keys(data);
Expand Down
4 changes: 2 additions & 2 deletions lib/storage/__mocks__/index.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import _ from 'underscore';
import fastMerge from '../../fastMerge';
import utils from '../../utils';

let storageMapInternal = {};

Expand Down Expand Up @@ -27,7 +27,7 @@ const idbKeyvalMock = {
_.forEach(pairs, ([key, value]) => {
const existingValue = storageMapInternal[key];
const newValue = _.isObject(existingValue)
? fastMerge(existingValue, value) : value;
? utils.fastMerge(existingValue, value) : value;

set(key, newValue);
});
Expand Down
4 changes: 2 additions & 2 deletions lib/storage/providers/IDBKeyVal.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import {
promisifyRequest,
} from 'idb-keyval';
import _ from 'underscore';
import fastMerge from '../../fastMerge';
import utils from '../../utils';

// We don't want to initialize the store while the JS bundle loads as idb-keyval will try to use global.indexedDB
// which might not be available in certain environments that load the bundle (e.g. electron main process).
Expand Down Expand Up @@ -55,7 +55,7 @@ const provider = {
return getValues.then((values) => {
const upsertMany = _.map(pairs, ([key, value], index) => {
const prev = values[index];
const newValue = _.isObject(prev) ? fastMerge(prev, value) : value;
const newValue = _.isObject(prev) ? utils.fastMerge(prev, value) : value;
return promisifyRequest(store.put(newValue, key));
});
return Promise.all(upsertMany);
Expand Down
77 changes: 77 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import * as _ from 'underscore';
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that there is a utils.js file, is there more stuff that can be moved here? Maybe the fast merge stuff?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, makes sense.


function areObjectsEmpty(a, b) {
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this implementation is too specific in that it requires two objects. I think there are already existing methods to check if a single object is empty, and then it's just as easy as doing if (_.isEmpty(a) && _.isEmpty(b)) and then it makes this utility method go away.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

_.isEmpty(8) is giving me true. Because we don't know what Onyx is sending to the connection, we only really want to do this check to avoid unnecessary re-renders on objects (and maybe arrays? I didn't see any array while debugging)

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are some arrays, but they are less frequently used.

This one comes to mind: https://github.com/Expensify/App/blob/48437acb34dbc38b9b7a60dae4c272727b8ce3ab/src/libs/actions/ActiveClients.js#L9

return (
typeof a === 'object'
&& typeof b === 'object'
&& _.isEmpty(a)
&& _.isEmpty(b)
);
}

// Mostly copied from https://medium.com/@lubaka.a/how-to-remove-lodash-performance-improvement-b306669ad0e1

/**
* @param {mixed} val
* @returns {boolean}
*/
function isMergeableObject(val) {
const nonNullObject = val != null ? typeof val === 'object' : false;
return (nonNullObject
&& Object.prototype.toString.call(val) !== '[object RegExp]'
&& Object.prototype.toString.call(val) !== '[object Date]');
}

/**
* @param {Object} target
* @param {Object} source
* @returns {Object}
*/
function mergeObject(target, source) {
const destination = {};
if (isMergeableObject(target)) {
// lodash adds a small overhead so we don't use it here
// eslint-disable-next-line rulesdir/prefer-underscore-method
const targetKeys = Object.keys(target);
for (let i = 0; i < targetKeys.length; ++i) {
const key = targetKeys[i];
destination[key] = target[key];
}
}

// lodash adds a small overhead so we don't use it here
// eslint-disable-next-line rulesdir/prefer-underscore-method
const sourceKeys = Object.keys(source);
for (let i = 0; i < sourceKeys.length; ++i) {
const key = sourceKeys[i];
if (source[key] === undefined) {
// eslint-disable-next-line no-continue
continue;
}
if (!isMergeableObject(source[key]) || !target[key]) {
destination[key] = source[key];
} else {
// eslint-disable-next-line no-use-before-define
destination[key] = fastMerge(target[key], source[key]);
}
}

return destination;
}

/**
* @param {Object|Array} target
* @param {Object|Array} source
* @returns {Object|Array}
*/
function fastMerge(target, source) {
// lodash adds a small overhead so we don't use it here
// eslint-disable-next-line rulesdir/prefer-underscore-method
const array = Array.isArray(source);
if (array) {
return source;
}
return mergeObject(target, source);
}

export default {areObjectsEmpty, fastMerge};
Loading
Loading