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

Fix: nullish keys not removed/merged in IDB-Keyval (web) #333

Merged
Merged
Show file tree
Hide file tree
Changes from 8 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
10 changes: 8 additions & 2 deletions lib/DevTools.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import {connectViaExtension} from 'remotedev';
import _ from 'underscore';

const {connectViaExtension} = process.env.NODE_ENV === 'production' ? require('remotedev') : {
connectViaExtension: () => ({
init: () => {},
send: () => {},
}),
};

class DevTools {
/**
chrispader marked this conversation as resolved.
Show resolved Hide resolved
* @callback onStateChange
Expand Down Expand Up @@ -42,7 +48,7 @@ class DevTools {
*/
clearState(keysToBeRemoved = []) {
const pairs = _.map(keysToBeRemoved, key => [key, undefined]);
this.registerAction('CLEAR', undefined, _.object((pairs)));
this.registerAction('CLEAR', undefined, _.object(pairs));
}
}

Expand Down
25 changes: 4 additions & 21 deletions lib/Onyx.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import createDeferredTask from './createDeferredTask';
import fastMerge from './fastMerge';
import * as PerformanceUtils from './metrics/PerformanceUtils';
import Storage from './storage';
import Utils from './utils';
import DevTools from './DevTools';

// Method constants
Expand Down Expand Up @@ -935,24 +936,6 @@ function hasPendingMergeForKey(key) {
return Boolean(mergeQueue[key]);
}

/**
* We generally want to remove top-level nullish values from objects written to disk and cache, because it decreases the amount of data stored in memory and on disk.
* On native, when merging an existing value with new changes, SQLite will use JSON_PATCH, which removes top-level nullish values.
* To be consistent with the behaviour for merge, we'll also want to remove nullish values for "set" operations.
* On web, IndexedDB will keep the top-level keys along with a null value and this uses up storage and memory.
* This method will ensure that keys for null values are removed before an object is written to disk and cache so that all platforms are storing the data in the same efficient way.
* @private
* @param {*} value
* @returns {*}
*/
function removeNullObjectValues(value) {
if (_.isArray(value) || !_.isObject(value)) {
return value;
}

return _.omit(value, objectValue => _.isNull(objectValue));
}

/**
* Write a value to our store with the given key
*
Expand All @@ -970,7 +953,7 @@ function set(key, value) {
Logger.logAlert(`Onyx.set() called after Onyx.merge() for key: ${key}. It is recommended to use set() or merge() not both.`);
}

const valueWithNullRemoved = removeNullObjectValues(value);
const valueWithNullRemoved = Utils.removeNullObjectValues(value);

const hasChanged = cache.hasValueChanged(key, valueWithNullRemoved);

Expand Down Expand Up @@ -1097,14 +1080,14 @@ function merge(key, changes) {
delete mergeQueue[key];

// After that we merge the batched changes with the existing value
const modifiedData = removeNullObjectValues(applyMerge(existingValue, [batchedChanges]));
const modifiedData = Utils.removeNullObjectValues(applyMerge(existingValue, [batchedChanges]));

// On native platforms we use SQLite which utilises JSON_PATCH to merge changes.
// JSON_PATCH generally removes top-level nullish values from the stored object.
// When there is no existing value though, SQLite will just insert the changes as a new value and thus the top-level nullish values won't be removed.
// Therefore we need to remove nullish values from the `batchedChanges` which are sent to the SQLite, if no existing value is present.
if (!existingValue) {
batchedChanges = removeNullObjectValues(batchedChanges);
batchedChanges = Utils.removeNullObjectValues(batchedChanges);
}

const hasChanged = cache.hasValueChanged(key, modifiedData);
Expand Down
4 changes: 2 additions & 2 deletions lib/fastMerge.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ function mergeObject(target, source) {
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) {
const isArray = Array.isArray(source);
chrispader marked this conversation as resolved.
Show resolved Hide resolved
if (isArray || source == null) {
chrispader marked this conversation as resolved.
Show resolved Hide resolved
return source;
}
return mergeObject(target, source);
Expand Down
11 changes: 10 additions & 1 deletion lib/storage/providers/IDBKeyVal.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
} 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 @@ -54,9 +55,17 @@ const provider = {

return getValues.then((values) => {
const upsertMany = _.map(pairs, ([key, value], index) => {
// If the value is null, we want to delete the key from storage,
// to be consistent with the native implementation with SQLite.
// SQLite by default removes keys from storage that are set to nullish values
if (_.isUndefined(value) || _.isNull(value)) {
Copy link
Collaborator

Choose a reason for hiding this comment

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

When would it be possible for value to be undefined? Wouldn't that mean that one of the pairs has only a key?

Copy link
Contributor

Choose a reason for hiding this comment

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

The value part of the pair could also be undefined like ['something', undefined].

Copy link
Contributor

Choose a reason for hiding this comment

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

I am sort of wondering how/why that happened though. It seems inconsistent with the merge behavior. Setting something to null has a special significance (i.e. delete me). Setting it to undefined does not mean anything.

I would guess that we would just throw these values out vs. allow them to overwrite / delete any keys.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just added a [HOLD on #334] tag to this PR. The SQLite crash PR actually fixes two things that are expected for this PR as well.

  • Allow undefined values to be passed to fastMerge
  • Remove key in Onyx.merge if null is passed

So we'll first want to merge the other PR in order for this to continue 👍

Copy link
Contributor Author

Choose a reason for hiding this comment

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

This way, the extra condition in IDBKeyVal.multiMerge is not needed anymore

return promisifyRequest(store.delete(key));
}

const prev = values[index];
const newValue = _.isObject(prev) ? fastMerge(prev, value) : value;
return promisifyRequest(store.put(newValue, key));

return promisifyRequest(store.put(Utils.removeNullObjectValues(newValue), key));
});
return Promise.all(upsertMany);
});
Expand Down
21 changes: 21 additions & 0 deletions lib/utils.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import _ from 'underscore';

/**
* We generally want to remove top-level nullish values from objects written to disk and cache, because it decreases the amount of data stored in memory and on disk.
* On native, when merging an existing value with new changes, SQLite will use JSON_PATCH, which removes top-level nullish values.
* To be consistent with the behaviour for merge, we'll also want to remove nullish values for "set" operations.
* On web, IndexedDB will keep the top-level keys along with a null value and this uses up storage and memory.
* This method will ensure that keys for null values are removed before an object is written to disk and cache so that all platforms are storing the data in the same efficient way.
* @private
* @param {*} value
* @returns {*}
*/
function removeNullObjectValues(value) {
if (_.isArray(value) || !_.isObject(value)) {
return value;
}

return _.omit(value, objectValue => _.isNull(objectValue));
}

export default {removeNullObjectValues};
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "react-native-onyx",
"version": "1.0.74",
"version": "1.0.75",
"author": "Expensify, Inc.",
"homepage": "https://expensify.com",
"description": "State management for React Native",
Expand Down
Loading