nx-js / observer-util

Transparent reactivity with 100% language coverage. Made with ❤️ and ES6 Proxies.
MIT License
1.2k stars 94 forks source link

Redundant reactions? #16

Closed lukeburns closed 6 years ago

lukeburns commented 6 years ago

I'm confused about the behavior of the following code:

const list = observable(['Hello'])
observe(() => console.log(list))
list.push('World!')

This logs ['Hello', 'World!'] twice. I expect it to log once. I get expected behavior from:

const list = observable(['Hello'])
observe(() => console.log(list.concat()))
list.push('World!')

Is this the correct behavior?

solkimicreb commented 6 years ago

Hi!

This is normal, but you probably want to do something different.

By default reactions are executed synchronously on observable mutations. This usually results in reactions running a lot of times for complex mutations.

There is a lot going on when you do list.push('World!'). list.length, list[1] and a hidden key for object enumeration changes. console.log(list) uses list.length and the hidden enumeration key to do its work so it updates twice (on each of the two key mutations). This will just get worse with more complex functions. I don't know your use case, but I am pretty sure that you don't want to run reactions synchronously on observable mutations.

You can pass a scheduler to observe. In this case the triggered reaction is not executed synchronously on observable mutations, but passed to the scheduler function instead. Take a look at this example:

import { observe, observable } from '@nx-js/observer-util'
import { Queue, priorities } from '@nx-js/queue-util'

const list = observable(['Hello'])

const scheduler = new Queue(priorities.LOW)
observe(() => console.log(list), { scheduler })

// this passes the reaction to the scheduler
// the scheduler logs ['Hello', 'World!'] once when the browser/Node has some free time
list.push('World!')

Typically a scheduler does two things:

You can use custom schedulers, but I advise you to use the @nx-js/queue-util. You can learn more about schedulers in this docs section, be sure the check all three examples.

I hope this helped 🙂

Edit: By using a bunch of schedulers with different priorities, you can implement something similar to React Fiber

Edit2: A debugger option is under development for observe, which gives you an exact reason for every reaction trigger. It shows you which observable property mutation scheduled the reaction and where does the reaction uses the specific observable property.

solkimicreb commented 6 years ago

@lukeburns can I close this?

lukeburns commented 6 years ago

Yep! Thanks for the helpful explanation, and the update on the debug option under development.

solkimicreb commented 6 years ago

I tweaked this behavior a bit in v4.1.0. From now on any atomic operation is guaranteed to schedule a reaction maximum once. By atomic operation I mean a JS operation, which is implemented in none interceptable native code (like property get/set).

Also I added a new experimental debugger. The easiest way of trying it out is this:

let dummy

const person = observable({ name: 'Bob' })
observe(() => dummy = person.name, { debugger: console.log })

person.name = 'Rick'

This logs the context of every single operation that is related to the observed reaction. It will log that person.name is used with a get operation in the reaction and that person.name changed value later. From this stream of data you can deduce why The Observer Util decided to schedule the reaction. The debugger API is designed to be raw. It pushes out a lot of metadata, which meant to be aggregated and filtered by 3rd party libs for larger projects.

lukeburns commented 6 years ago

Thanks for your work on this. This change certainly gives me the behavior I originally expected with the code above!

However, I'm receiving a Max call stack size exceeded error with the dummy code you shared above.

Additionally,

const person = observable({ name: 'Bob' })
observe(() => console.log(person))
person.name = 'Rick'

only logs { name: 'Bob' }, when I expect { name: 'Rick' } to also be logged — as in version 4.0.

solkimicreb commented 6 years ago

What platform are you on? Did you get this result in NodeJS or the browser? (For me it seems to be only buggy in NodeJS).

lukeburns commented 6 years ago

Interesting! I'm using node. Wonder why it would be platform specific. Different Proxy implementations?

solkimicreb commented 6 years ago

I fixed the first (infinite loop issue) in the 4.1.1 release. Thanks for catching the bug 🙂

The second issue is specific to the NodeJS console.log implementation. Normal for in loops and object enumeration works, but apparently NodeJS console.log is implemented in native code. It is not getting person.name under the hood (you can see this with the now working debugger).

I will see if this can or should be fixed. Is this a big issue for you? I imagine this would only be used for debugging purposes.

Edit: instead of getting each property key, Node gets a shady inspect prop for console.log. I guess Object.prototype has a custom inspect prop in Node which points to some native magic.