-
Notifications
You must be signed in to change notification settings - Fork 3k
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
[Discussion] Onyx.connect() design leads to ambiguously defined local values #6151
Comments
This solution seems a bit "greedy",has unnecessary complexity and will have problems in async scenarios
Here are some specific examples When an action calls a function that also need Onyx data, it would have to make Onyx data available to that function (e.g. include more keys in App/src/libs/actions/Report.js Lines 540 to 547 in 910348a
The action calling updateReportWithNewAction would not only have to provide the existing parameters but also dependencies like lastReadSequenceNumbers and currentAccountId . The result is increased coupling as now the caller provides internals
What about an action calling another action - I assume we'll be calling the other action through the designed interface, which should hopefully use the latest // This is part of `Reports` actions
function fetchReports(mappings) {
// Triggers another action not part of Reports
App.setSomethingIsLoading();
API.fetchReports(mappings.currentUser)
.then(reports => {
reports.forEach(report => {
// We can either call this action through the designed interface
Reports.fetchActions(report.reportID);
// Or since the function is already available in scope we can call it directly
// I assume that param taking actions would expect `mappings` as last parameter
fetchActions(report.reportID, mappings);
// Also keep in mind our value of `mappings` is that from the start of the call
// in this promise block the value in storage could already be different
})
})
} |
In the end it seems function fetchReports(mappings) {
API.fetchReports(mappings.get('currentUser'))
.then(returnedReports => {
App.setInitialDataLoaded();
setTimeout(() => {
// See this
const reportMaxSequenceNumbers = mappings.get('reportMaxSequenceNumbers', []);
const reportIDsWithMissingActions = _.chain(returnedReports)
.map(report => report.reportID)
.filter(reportID => isReportMissingActions(reportID, reportMaxSequenceNumbers[reportID]))
.value();
}, CONST.FETCH_ACTIONS_DELAY.STARTUP)
})
} Well isn't the above very similar to this function fetchReports() {
Onyx.get('currentUser')
.then(user => API.fetchReports(user))
.then(returnedReports => {
App.setInitialDataLoaded();
setTimeout(() => {
Onyx.get('reportMaxSequenceNumbers')
.then(maxSequenceNumbers => {
const reportIDsWithMissingActions = _.chain(returnedReports)
.map(report => report.reportID)
.filter(reportID => isReportMissingActions(reportID, maxSequenceNumbers[reportID]))
.value();
})
}, CONST.FETCH_ACTIONS_DELAY.STARTUP)
})
})
}
One thing that would improve the 2nd example is using async function fetchReports() {
const user = await Onyx.get('currentUser');
const reports = await API.fetchReports(user);
App.setInitialDataLoaded();
await delay(CONST.FETCH_ACTIONS_DELAY.STARTUP);
const maxSequenceNumbers = await Onyx.get('reportMaxSequenceNumbers');
const reportIDsWithMissingActions = _.chain(reports)
.map(report => report.reportID)
.filter(reportID => isReportMissingActions(reportID, maxSequenceNumbers[reportID]))
.value();
} The race condition problems are coming from trying to find a way to use storage in a |
Now tell me what you like about it ? So yeah, points taken and I agree with a lot of it and mainly tried to come up with something to kick off this conversation that could maybe satisfy our constraints of A - Not wanting excessive promise chains
We could solve it by having a particular method blocked by the "readiness" of only the things it needs
That might not be a bad thing as we already need to learn
I feel like this is OK as long as long as we like whatever solution we come up with. e.g. it would be a lot of work to replace all local variables with
I think you pretty much solved this with your next comment: #6151 (comment). @roryabraham had a similar idea here.
Yes, but violates constraints A and B
Yes, that is constraint C |
I think we should revisit some of these constraints. I've seem to have set a trap for myself with the Promise based example here but there are ways to make the promise chain flatter e.g. Regarding
Yeah but now we'll need to define mappings for each method and we still need to consider other actions or functions being called inside it - e.g. if someone decide to call a certain function they need to remember to update the mappings
We can replace local variables with |
IMO if we go with the
Hmm this would not work if we need to access something as report actions though ... |
👍 |
TL;DR: I think
|
Great thoughts here.
Close, but not quite - we have been avoiding its introduction entirely. I think the problem with the
We can create custom eslint rules, but this feels flexible given the context. So, I think first we need to figure out what are the cases where Anyways, I hope this helps think about the problem some more. And maybe we can think of a few more solutions to solve the "readiness" issue while not maintaining the original spirit of the project. It doesn't feel like this particular problem is severe enough for us to give up on that just yet. |
In the cases we're needing Here's a practical application where I don't find anything else suitable but
Actions already deal and wait for promises whether you wait on a http request or on Onyx.get doesn't make a difference to how the end result is handled So far I've provided sound reasons to why we need |
I'm not sure what more to add here. We have had bad experiences with combining cache getters and subscriptions and are trying to do something new here. If there's no solution then we can at least try to come up with clear rules about when to use a get pattern. |
Subscriptions use The only mishap that I can see with people using it is similar to what I've shared earlier function someAction() {
Onyx.get(myKey)
.then(myValue => {
makeRequest()
.then(() => /* can't rely on myValue here - you have to get it again */)
})
} So maybe we can enhance connections with a promise to get the best of both worlds // top level connection call
const someConnection = Onyx.connect({
key: 'myKey',
callback: ...
});
function someAction() {
someConnection.promise()
.then(() => {
if (someConnection.value == 'go') {
return makeRequest();
}
})
.then(() => {
// someConnection.value is kept up to date and can be used here
});
}
let myValue;
Onyx.connect({
key: 'myKey',
callback: val => myValue = value;
}); The downside of this is keeping the connection "forever" - Onyx.get allows us to release unused resources |
Interesting! This might be over-engineering, but makes me think we could return a higher order function from let testOne;
let testTwo;
const withConnectionResolved = Onyx.connectMultiple([
{key: 'testOne', callback: val => testOne = val},
{key: 'testTwo', callback: val => testTwo = val},
], () => {//...values are ready});
function somePrivateAction() {
//... do stuff with values
}
const somePublicAction = withConnectionResolved(somePrivateAction);
That's true. But I think if you look at the code we have a zillion examples of |
A general disadvantage here is that we always have to "remember" that values are not "ready" by default. So the pattern feels more like a smell than a design - but maybe acceptable if we do not run into this problem very often. |
I think this is what reactive extensions is about. For example, we could think that const myKeyStream = Onyx.connect('myKey');
// we can subscribe to that stream to get multiple values over time
myKeyStream.subscribe(val => myValue = value);
// we can get the latest value once
myKeyStream.take(1).subscribe(val => myValue = value);
// we can convert the previous stream to a promise (https://rxjs.dev/api/index/class/Observable#topromise-)
// (which would be the same as `Onyx.get`)
myKeyStream.take(1).toPromise();
// and we can combine (https://rxjs.dev/api/index/function/forkJoin) different streams and be notified when
// all of them are completed (getting only the latest value from both streams once)
forkJoin([
Onyx.connect('testOne').take(1),
Onyx.connect('testTwo').take(1),
]).subscribe(([valueOne, valueTwo]) => {
// ...
}); |
My little reminder says "Hi" function connect(params) {
// Something will keep these up to date
let currentValue;
let isReady = false;
/* ... */
return {
id: connectionId,
promise: connectionPromise,
disconnect: () => Onyx.disconnect(connectionId),
get value() {
if (!isReady && _.isUndefined(params.defaultValue) && __DEV__) {
throw new Error(
`Accessing connection value before it has been properly initialised.
Use the connection promise or provide a defaultValue`
);
}
if (hasDisconnected && __DEV__) {
throw new Error('Using a disconnected connection');
}
if (isReady) {
return currentValue;
}
Log.info('Connection is not ready - using default value');
return params.defaultValue;
}
}
}
Whoever defines a top level connection that tries to use the value too soon will be reminded to be more careful and take action |
RxJS could work, but IMO will reduce the people that can work on the project If we return an object from function observableConnection(key) {
return new Observable((subscriber) => {
Onyx.connect({
key,
callback: value => subscriber.next(value);
});
} Though we're lacking some callbacks like |
Thanks everyone! Chatting w/ @marcochavezf and we are going to close this for now and refer to it when we are ready to implement a solution. We've got a lot of ideas for how to address this issue more holistically, but want to wait for some more concrete issues to pop up before proposing anything formally. |
cc @roryabraham @kidroca
Problem
Onyx.connect()
updates local variables asynchronously wherever it is used. This has at times lead to incorrect assumptions about when a certain local variable will be "ready" or "not ready" and therefore increases the chances of race conditions (inconsistent results) and confusing time dependencies (do x, but only if y is "ready").Why is this important?
While there haven't been a ton of scenarios where we have badly assumed that some Onyx value is "ready" I have personally encountered enough that I am assuming it is likely to happen again in the future. It is at the very least something we should watch out for.
Solution
There have been a several proposed solutions to this problem:
Onyx.get()
Onyx.isReady()
promise that resolves when the value is readywithOnyx()
for use in actions files so that any actions will automatically get queued until the values are returned.The text was updated successfully, but these errors were encountered: