I have a Idea:
Promise instance.resolved or rejected calculation should be restarted automatically.waiting state (when we can't render until data loaded, we should render placeholder).Please include a sample application / setup in the discussions. There are no promises or async pieces in MobX itself, so I'm not sure how to read this proposal?
Some domain entity:
class User {
firstName: string
lastName: string
}
Lazy proxy:
function lazy< Entity >( entity : Entity , fetch: ( field : PropertyKey )=> Promise<any> ) {
return new Proxy( entity , {
get : ( entity , field )=> {
if( entity[ field ] === undefined ) throw fetch( field )
return entity[ field ]
} ,
} )
}
Lazy fetchable domain entity:
const user = new User
const lazyUser = lazy( user , async ( field )=> {
const uri = ( field === 'lastName' ) ? '/users/details' : '/user'
const json = await ( await fetch( uri ) ).json()
Object.assign( user , json.data.user )
} )
Somewehe in template
@observer
class PageTitle {
@computed get fullUserName() {
return `${ this.props.user.firstName } ${ this.props.user.lastName }`
}
render() {
try {
return <h1>{ this.props.config.showName && `[${ this.fullUserName }] ` } { this.props.page.title }</h1>
} catch( error ) {
if( error instanceof Promise ) return <LoadingView />
return <ErrorView { error } />
}
}
}
Simple example (with mobx usage in master) in this test: https://github.com/mobxjs/mobx/pull/1647/commits/1220eee8b74ccc1552fd55af1fc8c4d09a9606c0#diff-19c935498f7daabdc3c5f9a02361e212
But this issue is about masters without mobx like example below.
This behavior can be achieved by current mobx easily:
function lazy< Entity >( entity : Entity , fetch: ( field : PropertyKey )=> Promise<any> ) {
return new Proxy( entity , {
get : ( entity , field )=> {
if( entity[ field ] === undefined ) {
const atom = mobx.createAtom(field.toString());
atom.reportObserved();
const promise = fetch( field );
promise.then(() => atom.reportChanged(), () => atom.reportChanged());
throw promise;
}
return entity[ field ]
} ,
} )
}
Another way:
function lazy< Entity >( entity : Entity , fetch: ( field : PropertyKey )=> Promise<any> ) {
return new Proxy( entity , {
get : ( entity , field )=> {
if( entity[ field ] === undefined && !mobx.isObservableProp(entity, field)) {
mobx.extendObservable(entity, { [field]: undefined });
mobx.get(entity, field);
throw fetch( field );
}
return entity[ field ]
} ,
} )
}
@mayorovp, your examples like my second example requires mobx in master. Think about crosslibrary communications. Currently, we can't use future-fetcher, $mol_fiber and other Suspense API masters inside MobX.
@nin-jin No problem:
function catchSuspense<T>(fn: () => T) {
try { return fn(); }
catch (error) {
if (error instanceof Promise) {
const atom = mobx.createAtom("suspense");
atom.reportObserved();
error.then(() => atom.reportChanged(), () => atom.reportChanged());
}
throw;
}
}
@mayorovp I don't like to remember about manually wrapping of every call to additional wrapper. There is SuspenseAPI, which supported by other libraries. I think MobX should support it natively. $mol_atom2 will support it too.
@mayorovp
catchSuspense adds suspense magic for whole component computations, not for each used computable value.
catch section in catchSuspense never calls, if fn contains custom error/loading handler without rethrow:
function MyComponent(props) {
return catchSuspense(() => {
try {
return <div>{props.user.name}</div>
} catch(error) {
// Custom error/loading handling
}
})
}
Better to handle promise inside atom, when accessing props.user.name.
@mweststrate
Mobx is about reactivity, but reactivity tightly coupled with asyncrony. What if extends base mobx idea to asynchrony and produce pseudo-synchronous code. React tries to do this via suspense api and promises and it's a good idea.
Currently, mobx/computed not compatible (not transparent) with react suspense api.
Below example produce "An update was suspended for longer than the timeout, but no fallback UI was provided." Without @computed decorator suspense works fine.
class DogRepository {
random() {
if (this._cachedDog) return this._cachedDog;
throw fetch("https://dog.ceo/api/breeds/image/random")
.then(res => res.json())
.then(data => {
this._cachedDog = data.message;
});
}
}
class MobxStore {
@observable count = 0;
constructor(dogs) { this.dogs = dogs; }
// Remove @computed to show suspense api in action
@computed get some() {
return {
count: this.count,
dog: this.dogs.random()
};
}
@action.bound add() { this.count++; }
}
@observer class App extends React.Component {
render() {
const { store } = this.props;
return (
<div>
<button onClick={store.add}>Add</button>: {store.count}
<br />
<img src={store.some.dog} />
</div>
);
}
}
const store = new MobxStore(new DogRepository());
Pseudo-synchronous code is less boilerplate (no streams, no any visible data wrappers). Some example from my lom_atom todomvc
export default class TodoRepository {
@mem get todos(): Todo[] {
return this._fetcher.get('/todos').json()
.map((data: ITodoData) => new Todo(data, this))
}
set todos(todos: Todo[]) {}
@zerkalica if fn contains custom error handler without rethrow then fn contains custom suspense handler and catchSuspense not needed
@mayorovp You right. But problem in non-trasparent computed behaviour with throw. Looks like computed caches exception.
@zerkalica of course it is! See https://mobx.js.org/refguide/computed-decorator.html#note-on-error-handling
Why you called this behavior "non-transparent"?
@mayorovp Not this, computable itself works like in documentation. But not in observer or autorun, look at my example above.
Or this example without mobx-react, where i simulate react behaviour:
function render() {
try {
document.body.innerHTML = JSON.stringify(store.some);
} catch (e) {
if (e instanceof Promise) {
if (cnt < 30) {
e.then(render);
document.body.innerHTML = "Loading...";
} else {
document.body.innerHTML = "Overflow";
}
cnt++;
} else {
throw e;
}
}
}
// Without autorun suspense works
autorun(() => {
render();
});
@zerkalica @nin-jin I am not sure whether I understand this thread correctly, but suspens + promises in from computes seems to work fine for me ...
The only thing I don't get is that the dog counter is not updated properly, which does work if the dogs themselves are not rendered.
@mweststrate It works properly, only if observable mobx cache used @observable dog. Remove @observable and got an infinite loop, computed caches Promise exception.
Dog may be in separate library and doesn't know about mobx but knows about library-independed suspense paradigm. It's not a bug, but not compatible with suspense behavour.
In react 16.4.2, used in you example, suspens disabled by default: see var enableSuspense = false;.
Use react-suspense-starter or 16.4.0-alpha.0911da3 version from my examples.
Behavior changes with different versions of suspense https://codesandbox.io/s/61yvp6lmj3
@zerkalica you might not have been using the latest version of that link (I reverted the build numbers), for me https://codesandbox.io/s/kmzyyw36xo works the same with or without @computed. Yes you have to throw the same promise every time, but that is the whole model of suspense, and not related to MobX, but to suspense.
@mweststrate What is whole model of suspense? Promise-based suspense does not limit us about cache realization. How to communicate with other libraries, with any cache realization?
Look at facebook simple-cache-provider, which used in Dan's suspense demo. It's cache not observable, how to use it with mobx?
Suspense can be used everywhere. It's a raw, but more common paradigm than mobx. Like threads or fibers.
I think I interpreted your previous comment wrong. Anyway at this point I don't see any problem in combining MobX and suspense nor any reason to change anything. So I think this issue can be closed until the is a clear example of what goes wrong.
But since suspense is not documented nor deeply understood yet, reasoning about the correct behavior is probably still highly speculative at this point
This example not clear?
@zerkalica So whats your model? You want computed not to cache thrown result in the special case that the thrown thing is a promise and instead recompute despite no obvious changes to its dependencies?
The model of mobx computed values has always been clear: computeds are "pure functions", or at least functions idempotent to their arguments, where the arguments are the dependent observables. Any operations that are impure in this sense are not guaranteed to behave in any particular way
In this case, a non-pure (in the mobx model sense) function is invoked from a computed, and without the special behavior of suspense, its result simply wouldn't ever update.
IMO the exception model of suspense is really one giant hack. Any library between the promise thrower and react may intercept exceptions and decide to handle them, somehow. Relying on intermediate libraries not doing that creates improper coupling, where every library has to deal with this special-cased react behavior and ensure not to touch those thrown promises. So, every single library between the thrower and react is coupled with the suspense throw API, which sucks. "Lets just pray that all of those libraries we're using make sure to ignore promises when catching errors!"
Unless we can generalize "suspensive" behavior to a level that makes it applicable to all code that can come between react and a promise thrower, I don't see how this model is going to work.
Closing this issue for now; React suspense works fine as it seems for now. Custom reactions with alternative schedulers can be build based of the Reaction and can be as far as I see build in user land code, where the try / catch can be internalized in the function that needs to be tracked
It's OK if you don't want to make your lib more interoperable :-) But try to use SuspenseAPI in some of your projects. It's very cool.
Most helpful comment
@mayorovp Not this, computable itself works like in documentation. But not in observer or autorun, look at my example above.
Or this example without mobx-react, where i simulate react behaviour:
raw-mobx-computable-recalculate sandbox