realm-js icon indicating copy to clipboard operation
realm-js copied to clipboard

Incorrect wrapping of proxy objects on every render

Open mfbx9da4 opened this issue 2 years ago • 8 comments

How frequently does the bug occur?

All the time

Description

https://github.com/realm/realm-js/blob/master/packages/realm-react/src/useObject.tsx#L78 @takameyer

// Wrap object in a proxy to update the reference on rerender ( should only rerender when something has changed )
return new Proxy(object, {});

The intention of this code is that the reference will only change if the object has changed to play nicely with React's immutability conventions. However, the hook is not invoked only when the hook fires. It is invoked anytime the parent component renders. The component could be rendering because of a totally unrelated change to some other component state which will cause unnecessary Proxy wrapping, unnecessarily new references, break React's immutability conventions and contradicts the intention of this code.

Easier to explain with code

const initialObject = {};
function useObject() {
  return new Proxy(initialObject, {});
}

export default function App() {
  const [count, setCount] = useState(0);
  const object = useObject();
  useEffect(() => {
    // This effect will be invoked everytime the component
    // renders (eg click the button), even though the object
    // never actually changed
    console.log("object changed", object);
  }, [object]);

  return (
    <div className="App">
      <h1>{count}</h1>
      <button onClick={() => setCount((x) => x + 1)}>Click me</button>
    </div>
  );
}

https://codesandbox.io/s/modern-field-7ety98?file=/src/App.js:69-642

The fix here is to create the new proxy in the updateCallback.

I think a further improvement would be to have a singleton observable object with a single realm listener with a reference count referring to the number of active hooks using it. This would:

  • Reduce the overhead of fetching a new object each time because if the object is already in memory you can reuse it
  • Improve resilience by reducing potential gotchas with lots of realm listeners such as #4375
  • Using the same object via useObject in two different components will return the same reference and so works best with React's immutability tendencies.

Side issue: while the object cache and collection will make great strides to improving immutability, non-primitive values such as Dates, sets and dictionaries will not be immutably cached correctly. Are you aware of that?

Stacktrace & log output

No response

Can you reproduce the bug?

Yes, always

Reproduction Steps

No response

Version

main

What SDK flavour are you using?

Local Database only

Are you using encryption?

No, not using encryption

Platform OS and version(s)

ios

Build environment

Which debugger for React Native: ..

Cocoapods version

No response

mfbx9da4 avatar Apr 21 '22 17:04 mfbx9da4

@mfbx9da4 Good catch. Really appreciate the feedback. We will investigate this as time allows and get back to you when we have something in review.

takameyer avatar Apr 22 '22 07:04 takameyer

@mfbx9da4 We have a good idea for fixing the proxy wrapper. That being said, can you please make a separate issue for:

Side issue: while the object cache and collection will make great strides to improving immutability, non-primitive values such as Dates, sets and dictionaries will not be immutably cached correctly. Are you aware of that?

We would also appreciate an example where this is not being handled correctly 🙂 Thanks so much for your feedback!

takameyer avatar Apr 22 '22 09:04 takameyer

@takameyer what do you make of my singleton object proposal? Is there somewhere better to discuss that?

mfbx9da4 avatar Apr 23 '22 09:04 mfbx9da4

@mfbx9da4 I think it's worth looking into. We have discussed various ways we could improve or notification systems. I think making a feature request would be a good way to invoke more discussion.

takameyer avatar Apr 24 '22 05:04 takameyer

Is there any progress on this? In my app, I do a CPU-intensive operation using the data stored in the database. I tried the following:

const result = useMemo(() => {
   console.log('starting CPU intensive operation');
   return intensiveOperation(object);
}, [object]);

but it seems like this if fired every time the component state changes, not when the realm "object" dependency changes.

Is it what's described in this ticket?

maxencehenneron avatar Jun 15 '22 06:06 maxencehenneron

It looks like the same thing that is described @maxencehenneron, yes. There's a PR https://github.com/realm/realm-js/pull/4519 with a fix for this issue which we are reviewing right now.

tomduncalf avatar Jun 15 '22 09:06 tomduncalf

Hi, I noticed that useQuery has the same problem.

Is there a previous version where this works correctly? The README file caught my attention:

useQuery

Returns Realm.Results from a given type. This Hook will update on any changes to any Object in the Collection and return an empty array if the Collection is empty. The result of this can be consumed directly by the data argument of any React Native VirtualizedList or FlatList. If the component used for the list's renderItem prop is wrapped with React.Memo, then only the modified object will re-render.

const Component = () => {
  // ObjectClass is a class extending Realm.Object, which should have been provided in the Realm Config.
  // It is also possible to use the model's name as a string ( ex. "Object" ) if you are not using class based models.
  const collection = useQuery(ObjectClass);

  // The methods `sorted` and `filtered` should be wrapped in a useMemo.
  const sortedCollection = useMemo(() => collection.sorted(), [collection]);

  return (
    <FlatList data={sortedCollection} renderItem={({ item }) => <Object item={item}/>
  )
}

That is not working as expected, all items in my list are being re-rendered.

sdandois avatar Jun 22 '22 14:06 sdandois

As a workaround, I've implemented a caching hook on top of the ones provided by @realm/react:

export const createRefCache = object => {
  return new Proxy(
    {},
    {
      get: function (target, property) {
        return object[property];
      },
      set: function (target, property, value) {
        object[property] = value;
      },
    },
  );
};

export const useCachedObject = (object, otherDeps = []) => {
  const [counter, setCounter] = useState(0);
  const forceRender = () => setCounter(n => n + 1);

  // eslint-disable-next-line react-hooks/exhaustive-deps
  const memoObject = useMemo(() => createRefCache(object), [counter, ...otherDeps]);

  useEffect(() => {
    const listener = (obj, changes) => {
      if (changes.deletions.length || changes.insertions.length || changes.modifications.length) {
        forceRender();
      }
    };
    memoObject.addListener(listener);

    return () => {
      memoObject.removeListener(listener);
    };
  }, [memoObject]);

  return memoObject;
};

Let me hear what you think about it!

sdandois avatar Jul 11 '22 15:07 sdandois

This has been fixed in @realm/[email protected]

takameyer avatar Nov 03 '22 15:11 takameyer

@takameyer I'm not sure what I'm missing, but I clearly still observe the issue described in #4913 for both useQuery and useObject.

Android 10

"react-native": "^0.70.5",
"realm": "^11.2.0",
"@realm/react": "^0.4.1"

mordechaim avatar Nov 16 '22 22:11 mordechaim

I still experience issue for both useQuery and useObject

dimonnwc3 avatar Dec 02 '22 18:12 dimonnwc3

Argh, same. Just stumbled upon this issue after creating #5264 .

channeladam avatar Jan 15 '23 12:01 channeladam