-
Notifications
You must be signed in to change notification settings - Fork 92
WIP fix: Make storage calls async and await the init task #727
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
base: main
Are you sure you want to change the base?
WIP fix: Make storage calls async and await the init task #727
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we still need to return promises in all these cases. There is still code that uses them, isn't there?
5cf97ad to
e176ab0
Compare
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: e176ab0db3
ℹ️ About Codex in GitHub
Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".
lib/Onyx.ts
Outdated
| function update<TKey extends OnyxKey>(data: Array<OnyxUpdate<TKey>>): Promise<void> { | ||
| // First, validate the Onyx object is in the format we expect | ||
| for (const {onyxMethod, key, value} of data) { | ||
| if (!Object.values(OnyxUtils.METHOD).includes(onyxMethod)) { | ||
| throw new Error(`Invalid onyxMethod ${onyxMethod} in Onyx update.`); | ||
| } | ||
| if (onyxMethod === OnyxUtils.METHOD.MULTI_SET) { | ||
| // For multiset, we just expect the value to be an object | ||
| if (typeof value !== 'object' || Array.isArray(value) || typeof value === 'function') { | ||
| throw new Error('Invalid value provided in Onyx multiSet. Onyx multiSet value must be of type object.'); | ||
| return OnyxUtils.getDeferredInitTask().promise.then(() => { | ||
| // First, validate the Onyx object is in the format we expect | ||
| for (const {onyxMethod, key, value} of data) { | ||
| if (!Object.values(OnyxUtils.METHOD).includes(onyxMethod)) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Preserve synchronous validation errors in update
Wrapping the entire body in getDeferredInitTask().promise.then(...) makes the validation errors throw asynchronously. Callers (and tests like tests/unit/onyxTest.ts around the “should throw an error…” case) currently rely on a synchronous throw from Onyx.update to fail fast when invalid data is passed; with this change, the same misuse becomes an unhandled promise rejection unless every caller awaits/catches the returned promise. This is a behavior change that breaks existing try/catch usage and can let invalid updates slip past immediate error handling.
Useful? React with 👍 / 👎.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@adhorodyski @roryabraham @tgolen
Overall the comment is right about these throws in Onyx.update. In the particular case where some Onyx.updates were called before initialisation and they happen to fail after Onyx.init, they won't throw synchronous errors anymore as they were chained in getDeferredInitTask().promise.then(...). That would represent a problem in E/App if we were handling them with try/catch, but:
- I couldn't find any logic in E/App where we are using try/catch to handle Onyx.update synchronous failures. In fact, we rarely handle the asynchronous Onyx failures too.
- This synchronous error throwing would made the application crash, it's the only Onyx method where we do this and it's even different from how we are handling wrong mergeCollection updates, just a few lines below in the same function.
So in this commit I made some adjustments to:
- Instead of throwing errors, handle these cases with
Logger.logInfoand skipping the individual operation in the same way we are doing with mergeCollection at the same function, so the error handling there are now standartised. - Fix the tests to accommodate these changes (actually these tests were never "working" before because of the way they were written)
Let me know what you think.
|
Updates:
|
lib/Onyx.ts
Outdated
| if (OnyxUtils.getDeferredInitTask().isResolved) { | ||
| return OnyxUtils.setWithRetry({key, value, options}); | ||
| } | ||
|
|
||
| return OnyxUtils.getDeferredInitTask().promise.then(() => OnyxUtils.setWithRetry({key, value, options})); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's DRY this pattern up into a utility method like performAction(() => OnyxUtils.setWithRetry({key, value, options}))
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
fair, applying this one
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
applied with afterInit, happy to change to a better name (wanted something more explicit)
Replace repeated deferredInitTask checks with OnyxUtils.afterInit to simplify async initialization handling for set, multiSet, merge, clear, update, and collection methods.
tgolen
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That looks great. Thank you!
|
Let's hold merge a bit, I'm adding some unit tests |
| expect(callback).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('should only execute the callback after Onyx initialization', async () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🙌🏻
roryabraham
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
One other idea to clean this up a bit would be to create a new file, OnyxInit, that encapsulates OnyxInit.init and the logic for the initialization promise. Right now, it's kind of awkwardly split between Onyx.ts and OnyxUtils.ts.
Ultimately I don't really have any strictly blocking comments though, so I'm approving.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Taking a look at this file, I think it could be replaced by Promise.withResolvers. We have a Polyfill for it in E/App (for Hermes).
I think that could end up being a nice simplification.
Even if you don't go that route, is there any advantage to adding isResolved as opposed to just awaiting the promise we've already got? How important is it the Onyx.set proceeds in the same microtask?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Taking a look at this file, I think it could be replaced by Promise.withResolvers. We have a Polyfill for it in E/App (for Hermes).
I think that could end up being a nice simplification.
Will have a look on this.
Even if you don't go that route, is there any advantage to adding isResolved as opposed to just awaiting the promise we've already got? How important is it the Onyx.set proceeds in the same microtask?
Answered here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Promise.withResolvers
Done, much better now 🎉
lib/Onyx.ts
Outdated
| // The key is a skippable one, so we set the new changes to undefined. | ||
| // eslint-disable-next-line no-param-reassign | ||
| changes = undefined; | ||
| const mergeOperation = () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
In my opinion keeping this anonymous inside Onyx.afterInit is a bit cleaner.
| @@ -136,6 +136,21 @@ function getDeferredInitTask(): DeferredTask { | |||
| return deferredInitTask; | |||
| } | |||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I know you didn't add them here, but I feel like the getters in this function don't provide any useful abstraction. In java, it's a common practice to have private members with public getters and public (semantic) setters.
This doesn't accomplish that - the "getters" just provide a mutable reference to a variable, which isn't really any more helpful than just exporting the variable directly. We could just get rid of these functions and return the variable directly, and it would be a simplification.
Alternatively, we could create semantic getters and setters based on logical usage (i.e: enqueueMerge, hasPendingMergeForKey, etc...), but keep the variables private. That seems like a better improvement.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If we're planning to keep the merge queue for the time being, I think it'd be cleaner to move that into a dedicated file like MergeQueue instead of OnyxUtils.ts. These utils files can grow very large and hard to follow.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(the comments in this thread can be out-of-scope for this PR btw, but I'd be happy to review a follow-up PR if you agree)
lib/Onyx.ts
Outdated
| // For multiset, we just expect the value to be an object | ||
| if (typeof value !== 'object' || Array.isArray(value) || typeof value === 'function') { | ||
| throw new Error('Invalid value provided in Onyx multiSet. Onyx multiSet value must be of type object.'); | ||
| const updateOperation = () => { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this is a bit clearer if you just keep the function anonymous.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Makes sense, it's like this because we wasn't using the extracted afterInit function before. I will adjust.
|
Updates:
@roryabraham I agree we check/address these comments (#727 (comment), #727 (comment)) in a follow-up. |
@roryabraham @sosek108 @tgolen
Details
This PR makes Onyx storage calls async so we can await the initialization task before executing them.
Related Issues
$ Expensify/App#81835
Automated Tests
Manual Tests
(With EApp)
2. In dev, put a new
Log.infocall really early on, before Onyx initializes (example)3. Log into the app
4. Navigate to Workspaces -> New -> New Workspace (
/workspaces/confirmation)5. Refresh the page
6. Observe the Currency field reporting
undefinedand navigating into it results in a missing list of currencies to choose from7. Remove the newly added
Log.infocall8. Go back to the app, refresh it
9. Observe currency is displayed correctly and the list (while navigating into the pick list) is displayed correctly
Author Checklist
### Related Issuessection aboveTestssectiontoggleReportand notonIconClick)myBool && <MyComponent />.STYLE.md) were followedAvatar, I verified the components usingAvatarare working as expected)/** comment above it */thisproperly so there are no scoping issues (i.e. foronClick={this.submit}the methodthis.submitshould be bound tothisin the constructor)thisare necessary to be bound (i.e. avoidthis.submit = this.submit.bind(this);ifthis.submitis never passed to a component event handler likeonClick)Avataris modified, I verified thatAvataris working as expected in all cases)mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari
MacOS: Desktop