pmndrs/valtio

Frozen page when proxying an instance of EventEmitter

StarpTech opened this issue · 23 comments

From #62 (comment)

The following code will result in:

Uncaught TypeError: 'set' on proxy: trap returned falsish for property 'Notification'
import EventEmitter from 'events';
import { proxy } from 'valtio';

export const state = proxy({
  signals: new EventEmitter()
});

// anywhere
const { signals } = useSnapshot(state);
useEffect(() => {
 signals.on('Notification', fn);
 return () => {
   return signals.off('Notification', fn);
 };
}, [signals)

Workaround

Avoid proxying EventEmitter with ref()

import { proxy, ref } from 'valtio';

export const state = proxy({
  isConnecting: false,
  signals: ref(new EventEmitter()),
  connection: null
});

I'd like to understand why it fails so badly. I'd assume that the proxy handles it transparently.

snapshot() returns a pure object.
useSnapshot() returns a wrapped snapshot object with another proxy for render optimization.
This is handled by proxy-compare. As snapshot is a frozen object, set is prohibited.

Object.freeze(snapshot)

https://github.com/dai-shi/proxy-compare/blob/015f3291dbdf5bccd9dd22c095afaf13febd4981/src/index.ts#L116-L118

ref() is to cancel this behavior, so it's a proper usage in the case of EventEmitter.

I'd assume that the proxy handles it transparently.

EventEmitter wouldn't work even without snapshot. proxy() creates a new tree from the initial object (recursively applying proxy()) and coping EventEmitter might be troublesome/impossible. To avoid this, ref() is introduced.

refSet.has(value) ||

Thanks for the explanation! Is there a chance to throw a more meaningful error in that case?

It would be nice but pretty difficult. The meaningful message could only be made at creation.
The safe bet is only use serializable objects for proxy() and wrap non-serializable objects with ref().

Do you know why EventEmitter is special and what do you mean by serializable in this context?

No. What would happen if you do this?

p = proxy(new EventEmitter())
p.on('Notification', fn)

By serializable, I almost meant JSON.serialize-able.

> new EventEmitter().domain
Domain {
  _events: [Object: null prototype] {
    removeListener: [Function: updateExceptionCapture],
    newListener: [Function: updateExceptionCapture],
    error: [Function: debugDomainError]
  },
  _eventsCount: 3,
  _maxListeners: undefined,
  members: [],
  [Symbol(kCapture)]: false,
  [Symbol(kWeak)]: WeakReference {}
}

null prototype looks unusual. WeakReference is not properly excluded. Not sure if this is the fundamental reason. There could be so many of them.

p = proxy(new EventEmitter())
p.on('Notification', fn)

produce no error.

The trigger is useSnapshot. It will throw in that isolated test with TypeError: Invalid value used as weak map key.

  93 | const getMutableSource = (proxyObject) => {
  94 |   if (!mutableSourceCache.has(proxyObject)) {
> 95 |     mutableSourceCache.set(proxyObject, createMutableSource(proxyObject, getVersion));
  96 |   }
  97 |   return mutableSourceCache.get(proxyObject);
  98 | };

null prototype looks unusual

This is pretty common to not inherit from the default Object prototype. Object.create(null).

Oh, so you don't have any issues unless you use useSnapshot?

It will throw in that isolated test with TypeError: Invalid value used as weak map key.

This means the proxyObject is not an object.

This is pretty common to not inherit from the default Object prototype. Object.create(null).

Yeah, maybe there's no issue on this.

Oh, so you don't have any issues unless you use useSnapshot?

Yes

Okay, so proxy-compare is the library used in useSnapshot for tracking state usage (= property access).
It basically supports plain objects.
https://github.com/dai-shi/proxy-compare/blob/015f3291dbdf5bccd9dd22c095afaf13febd4981/src/index.ts#L19-L25

However, in valtio, we found there's a good use case to define a state with class syntax.
So, to bypass the check in proxy-compare, we explicitly support object with prototype.

markToTrack(snapshot, true) // mark to track

However, this caused a problem when people put non plain objects like EventEmitter.
So, we discussed in #61, and took opt-out approach with ref().

markToTrack(value, false) // mark not to track

And, these exceptions confused you...

So, in summary, valtio supports plain objects and arrays, plus simple class syntax to produce plain objects,
and non-plain objects should be wrapped with ref().

From the developer's perspective, this is highly confusing because I assumed that I can assign any js value. I'd added it to the README.

Yeah, let's do that.

Don't you think that splitting state from none-serializable data like functions & Co would be a better design? In that way, we can throw meaningful errors and the separation is clear.

import { proxy } from 'valtio'

export const state = proxy({
  count: 0,
  name: 'foo',
})

export const actions = {
  inc: () => {
    ++state.count
  },
  setName: (name) => {
    state.name = name
  },
}

Disallowed (Only objects and arrays allowed)

export const state = proxy({
  count: 0,
  name: 'foo',
  inc() {
    ++this.count
  },
  setName(name) {
    this.name = name
  },
})

We accept both patterns: https://github.com/pmndrs/valtio/wiki/How-to-organize-actions
(I prefer the separation, but the lib allows both.)

So, "serializable-only" was a wrong conception.
I'm still wondering how to note this correctly and nicely.

Do you know why EventEmitter is special

I'd say plain objects (no custom prototype) or user-defined prototype (class syntax) are fine.
But, it sounds too technical and is not well said from the developer's perspective.

Not sure if #180 is something you expected, but for now I would like to modify the section about ref(). Let me know if you have suggestion modifying words there.

Oh, so you don't have any issues unless you use useSnapshot?

Yes

Actually, I got a clear explanation on this.
Here's the repro.

const state = proxy({
  count: 0,
  inc() {
    ++this.count;
  }
});

console.log("before", state.count);
state.inc(); // this works fine
console.log("after", state.count);

const snap = snapshot(state);
console.log("before", snap.count);
snap.inc(); // this doesn't work because it's trying to mutate frozen snapshot
console.log("after", snap.count);

https://codesandbox.io/s/adoring-lehmann-yji31?file=/src/App.js

https://github.com/pmndrs/valtio/wiki/How-to-organize-actions#action-methods-using-this is also using this.

Both cases are valid as long as we use state.inc(). We send "increment" command to "state".

Both cases are invalid when we do snap.inc(). We can't modify "snapshot".

I got it. The design is very hard to grasp. I'd expect an error like "Snapshot can't be modified, please use the state directly".

Actually, I got a clear explanation on this.
Here's the repro.

IMO this doesn't explain why eventEmitter doesn't work.

The design is very hard to grasp.

My recommendation for beginners is no this, and no prototype (aka class).

I'd expect an error like "Snapshot can't be modified, please use the state directly".

That would be nice. At JS level, it would be hard. Maybe a good challenge for eslint-plugin-valtio.

IMO this doesn't explain why eventEmitter doesn't work.

I don't know, but it's the only the reason I can think why state.signals.on('Notification', fn) works but snap.signals.on('Notification', fn) doesn't.

I wish I could get more feedback from anyone, but let me move forward.
This will be closed.
We can continue discussion here, in #171 or new discussion.