Skip to content
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

Change listeners on Results should always update React Native UI #2655

Closed
kraenhansen opened this issue Dec 13, 2019 · 21 comments · Fixed by #6791
Closed

Change listeners on Results should always update React Native UI #2655

kraenhansen opened this issue Dec 13, 2019 · 21 comments · Fixed by #6791
Labels

Comments

@kraenhansen
Copy link
Member

kraenhansen commented Dec 13, 2019

Goals

A callback registered as a listener for changes on a Realm JS Results instance should always be able to trigger updates of the React Native UI.

Expected Results

From a React Native app, when registering a callback on a Results object and calling this.setState on a component, I would expect the UI to always update.

Actual Results

  • The UI only updates 4 out of 5 times when the change listener fires.
  • The bug seems to disappear if this.setState is the first method called in the callback.
  • It's possible to trigger an update of the UI by touching / clicking the simulator.

Steps to Reproduce & Code Sample

Initialize a new React Native app:

npx react-native init Issue2655 --directory realm-js-issue-2655

Copy in the files below, install the dependencies

cd realm-js-issue-2655
npm install

Create an instance of ROS and update constants.js.

In one terminal, start the updater (a node process changing a realm every second) - in another start the app on iOS

npm run updater
npm run ios

The app has two modes: "interval" where a timer will update the state every second and "realm" where a change listener will be registered and the bug is observed.

Use the Safari developer tools to attach to the apps JSContext and observe the issue in the timeline:

// TODO: Add images of the timeline and call-stacks when running in the two modes.

package.json

{
  "name": "realm-js-issue-2655",
  "version": "0.0.1",
  "private": true,
  "scripts": {
    "android": "react-native run-android",
    "ios": "react-native run-ios",
    "start": "react-native start",
    "lint": "eslint .",
    "updater": "cd updater && node index.js"
  },
  "dependencies": {
    "react": "16.9.0",
    "react-native": "0.61.5",
    "realm": "^3.5.0"
  },
  "devDependencies": {
    "@babel/core": "^7.6.2",
    "@babel/runtime": "^7.6.2",
    "@react-native-community/eslint-config": "^0.0.5",
    "eslint": "^6.5.1",
    "metro-react-native-babel-preset": "^0.56.0"
  },
  "jest": {
    "preset": "react-native"
  }
}

App.js

import React, {Component} from 'react';
import {Button, Text, View} from 'react-native';
import Realm from 'realm';

import {schema} from './schema';
import {NICKNAME, SERVER_URL} from './constants';

const SEQUENCE = 'abcdefghijklmn';

const styles = {
  mainView: {
    justifyContent: 'center',
    height: '100%',
    padding: 10,
  },
};

export default class App extends Component {
  index = 1;

  constructor(props) {
    super(props);
    this.state = {value: 'Not yet set', mode: 'interval'};
    this.modeChanged(this.state.mode);
  }

  componentDidMount() {
    // Open up the Realm
    this.openRealm().then(null, err => {
      console.error(`Failed to open Realm: ${err.message}`);
    });
  }

  componentDidUpdate(_, prevState) {
    const {mode} = this.state;
    if (prevState.mode !== mode) {
      this.modeChanged(mode);
    }
  }

  componentWillUnmount() {
    if (this.realm) {
      this.realm.close();
    }
  }

  render() {
    console.log(`Rendered: ${this.state.value}`);
    return (
      <View style={styles.mainView}>
        <Text>
          Realm is "{this.realm && !this.realm.isClosed ? 'open' : 'closed'}"
        </Text>
        <Text>Mode is "{this.state.mode}"</Text>
        <Text>Value is "{this.state.value}"</Text>
        <Button
          title={`Toggle mode to ${
            this.state.mode === 'interval' ? 'realm' : 'interval'
          }`}
          onPress={this.toggleMode}
        />
      </View>
    );
  }

  ensureUser = async () => {
    if (Realm.Sync.User.current) {
      return Realm.Sync.User.current;
    } else {
      const credentials = Realm.Sync.Credentials.nickname(NICKNAME, true);
      return Realm.Sync.User.login(SERVER_URL, credentials);
    }
  };

  openRealm = async () => {
    let user = await this.ensureUser();

    const config = user.createConfiguration({
      schema,
      sync: {
        url: '~/issue-2655',
        fullSynchronization: true,
      },
    });

    this.realm = new Realm(config);
    this.singletons = this.realm.objects('Singleton');
  };

  callback = results => {
    const [singleton] = results;
    if (singleton) {
      const {value} = singleton;
      console.log(`Value changed to ${value}`);
      this.setState({value}, () => {
        console.log(`State was changed to ${this.state.value}`);
      });
    }
  };

  toggleMode = () => {
    this.setState({
      mode: this.state.mode === 'interval' ? 'realm' : 'interval',
    });
  };

  modeChanged = mode => {
    // Clear the interval if its mode is not interval
    if (mode !== 'interval') {
      clearInterval(this.interval);
    }
    // Remove the listener if the mode is not realm
    if (mode !== 'realm' && this.singletons) {
      this.singletons.removeListener(this.callback);
    }
    // Handle mode being set to interval
    if (mode === 'interval') {
      this.interval = setInterval(() => {
        const value = SEQUENCE.substring(0, this.index);
        this.callback([{value}]);
        this.index++;
        if (this.index > SEQUENCE.length) {
          this.index = 1;
        }
      }, 1000);
    }
    // When the mode becomes "realm", add a listener with the callback
    if (mode === 'realm' && this.singletons) {
      console.log('Setting listeners on', this.singletons);
      this.singletons.addListener(this.callback);
    }
  };
}

constants.js

module.exports = {
  NICKNAME: 'realm-js-issue-2655',
  SERVER_URL: 'https://[...].cloud.realm.io/', // Go to https://cloud.realm.io/ create an instance
};

schema.js

module.exports = {
  schema: [
    {
      name: 'Singleton',
      properties: {
        value: 'string',
      },
    },
  ],
};

updater/index.js

const Realm = require('realm');

const {NICKNAME, SERVER_URL} = require('../constants');
const {schema} = require('../schema');

const SEQUENCE = 'ABCDEFGHIJKLMN';
let index = 1;

function update(realm) {
  // Remove the first element of the list and insert a new at the end
  realm.write(() => {
    const value = SEQUENCE.substring(0, index);
    console.log(`Changing value to "${value}"`);
    const [singleton] = realm.objects('Singleton');
    if (singleton) {
      singleton.value = value;
    } else {
      realm.create('Singleton', {value});
    }
    // Increment the index
    index++;
    // Reset when it gets out of bounds
    if (index > SEQUENCE.length) {
      index = 1;
    }
  });
  realm.syncSession.uploadAllLocalChanges().then(() => {
    console.log('Done uploading!');
  });
}

function login() {
  if (Realm.Sync.User.current) {
    return Realm.Sync.User.current;
  } else {
    const credentials = Realm.Sync.Credentials.nickname(NICKNAME, true);
    return Realm.Sync.User.login(SERVER_URL, credentials);
  }
}

async function run() {
  const user = await login();
  const config = user.createConfiguration({
    schema,
    sync: {
      url: '~/issue-2655',
      fullSynchronization: true,
    },
  });
  const realm = new Realm(config);
  // Start updating
  setInterval(() => {
    update(realm);
  }, 1000);
}

run().then(null, err => {
  console.error(err.stack);
  process.exit(1);
});

Version of Realm and Tooling

  • Realm JS SDK Version: 2.6.0
  • Node or React Native: React Native (verified on iOS)
  • Client OS & Version: N/A
  • Which debugger for React Native: None
@thekevinbrown
Copy link

This seems like a duplicate of #2544 to me.

Basically Realm listeners fire too early, so any reads you do in the listener can return stale data.

@kraenhansen
Copy link
Member Author

kraenhansen commented Dec 17, 2019

@thekevinbrown - just reading over #2544, I don't think its the same.
The console.log(`Value changed to ${value}`); prints the correct (updated) value, while the call to setState fails to trigger an update to the UI.

@thekevinbrown
Copy link

To be sure I'm following, you're saying that you get correct data in the Realm listener, but when you call setState with that data, the UI doesn't update? And that data is a string? If that's the case it sounds like it's an issue with either React or your component, not a Realm issue.

Does the issue stay the same if you remove the modeChanged logic so you can simplify the logic to just:

  • Open Realm
  • Make change
  • Get callback
  • Log and setState

@kraenhansen
Copy link
Member Author

kraenhansen commented Dec 17, 2019

@thekevinbrown

To be sure I'm following, you're saying that you get correct data in the Realm listener, but when you call setState with that data, the UI doesn't update? And that data is a string?

Correct.

Does the issue stay the same if you remove the modeChanged logic.

Yeah - that's just there to show what happens if the thing calling the callback is a timer instead of a Realm change listener.

If that's the case it sounds like it's an issue with either React or your component, not a Realm issue.

I suspect it's the combination of the two, perhaps it's rooted in resource throttling / the CPU sleeping. Perhaps Realm JS needs to tell the OS that calling the callback might take more time than it is provided.

@thekevinbrown
Copy link

Odd behaviour for sure!

What if you change out the setState for more console.log calls? Or like a 10ms setTimeout between console.log calls in a loop?

If we can untangle React’s inner workings from this then it puts it much more squarely in Realm’s court, so that’s what I’m trying to see if we can prove.

@kraenhansen
Copy link
Member Author

kraenhansen commented Dec 17, 2019

What if you change out the setState for more console.log calls?

Only the first console.log would be printet in the Metro bundler output, but the Safari dev-tools will console log correctly.

If you run the code above in a simulator and attach using the Safari dev-tools and make a profiling, you get some very (!!) interesting results. I didn't have the time to dive more into it, but to me it looks like out of order execution of JS is happening 💥, which is probably just a false-positive - perhaps an artifact from how the profiler receives its data from the simulator.

@thekevinbrown
Copy link

The more you talk about this the more I think it’s the same as the issue I logged. I’ll see if I can dig into your example more.

@fealebenpae
Copy link
Member

fealebenpae commented Dec 17, 2019

Have you tried something like Promise.resolve().then(() => this.setState(...))? Perhaps it's a case of the JavaScript callback not being invoked on the right context, which is something we saw in Node.js way back when.

@thekevinbrown
Copy link

thekevinbrown commented Dec 17, 2019

@fealebenpae, in my repro on the other issue you can fix it with a setTimeout, but it had to be more than 15ms (and of course sometimes more than 30ms, wasn't consistent) of waiting before reads showed the new data. Our fix was to build our own listener system in JS and fire observer callbacks ourselves once the realm.write call returned, which caused the data to actually be persisted. We weren't using Realm.Sync which made it much easier for us to work around.

I would argue that it's Realm's job to ensure the write is persisted enough that reads will show the new data before calling a listener. @kneth said that this behaviour was a feature, not a bug, and that this way the UI updates faster. (Except it unfortunately doesn't because although the render is called more quickly, the data doesn't show on the screen, and we never get a notification when the data is 'really' there, so the listener ends up producing a UI that's buggy and doesn't show the data that's actually in the DB, it shows what was there before the write that's causing the re-render in the first place.)

@aureliopetrone
Copy link

Is there any update on this issue?

Apple has rejected my app for this bug.

@kneth
Copy link
Contributor

kneth commented Feb 3, 2020

@aureliopetrone Can you explain why it was rejected due to this?

@aureliopetrone
Copy link

aureliopetrone commented Feb 10, 2020

@kneth I use this code for displaying a spinner while the app synchronize my initial data from a query-synched realm. The problem is that since the promises are not resolved until I tap the screen the loading process seems infinite and the spinner doesn't go out.

        var realm = Database.getRealm()

        var class1 = realm.objects("Class1")
        var class2 = realm.objects("Class2")
        var class3 = realm.objects("Class3")

        await Promise.all(
            [
                Database.susbscribeAndSyncTo(class1),
                Database.susbscribeAndSyncTo(class2),
                Database.susbscribeAndSyncTo(class3),
            ]
        )

        // This console.log is not executed until I don't tap on the screen.  
        // More classes I add to this and more times I need to tap the screen
        console.log("Synched")

        return true


static susbscribeAndSyncTo = async (object, object_name) => {
        var subscription = object.subscribe()

        return new Promise((resolve, reject) => {
            subscription.addListener((subscription, state) => {
                if (this.checkSubscriptionState(state, object_name)) {
                    try {
                        subscription.removeAllListeners()
                    } catch (e) {
                        console.log(e.message)
                    }
                    resolve(true);
                }
            })
        });
    }
    static checkSubscriptionState = (state, object_type) => {
        switch (state) {
            case Realm.Sync.SubscriptionState.Creating:
                // The subscription has not yet been written to the Realm
                break;
            case Realm.Sync.SubscriptionState.Pending:
                // The subscription has been written to the Realm and is waiting
                // to be processed by the server
                break;
            case Realm.Sync.SubscriptionState.Complete:
                // The subscription has been processed by the server and all objects
                // matching the query are in the local Realm
                return true

                break;
            case Realm.Sync.SubscriptionState.Invalidated:
                // The subscription has been removed
                break;
            case Realm.Sync.SubscriptionState.Error:
                break;

            default:
                break;
        }

        return false
    }

So I guess the problem is not only with Results listeners but also with subscription ones.

@aureliopetrone
Copy link

aureliopetrone commented Feb 13, 2020

@kneth @kraenhansen @ericjordanmossman

@saravanakumargn
Copy link

This is my solution to refresh the data and rerender the UI.

  const [ignored, forceUpdate] = useReducer((x) => x + 1, 0);

   useEffect(() => {
    const taskLists: Realm.Results<TaskSchema & Realm.Object> = realm
      .objects<TaskSchema>(TaskSchema.schema.name)
      .filtered('dueDate >= $0 AND done = false', startOfDay(new Date()))
      .sorted('dueDate');
    taskLists.addListener(listener);   // Added Listener
    setTaskData(taskLists);

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

  function listener(allTasks: any, changes: any) {
    updateUI(allTasks);
  }

  function updateUI(taskItems: any) {
    setTaskData(taskItems);
    forceUpdate();
  }

@victorbutler
Copy link

This is my solution to refresh the data and rerender the UI.

  const [ignored, forceUpdate] = useReducer((x) => x + 1, 0);

   useEffect(() => {
    const taskLists: Realm.Results<TaskSchema & Realm.Object> = realm
      .objects<TaskSchema>(TaskSchema.schema.name)
      .filtered('dueDate >= $0 AND done = false', startOfDay(new Date()))
      .sorted('dueDate');
    taskLists.addListener(listener);   // Added Listener
    setTaskData(taskLists);

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

  function listener(allTasks: any, changes: any) {
    updateUI(allTasks);
  }

  function updateUI(taskItems: any) {
    setTaskData(taskItems);
    forceUpdate();
  }

Thank you! This isn't how it should work, but it definitely worked when I followed your pattern.

@cristianoccazinsp
Copy link

cristianoccazinsp commented Feb 5, 2021

I'm seeing the same issue randomly on iOS. Can anyone summarize what's the problem exactly and the best work-around?

This seems like an IOS only problem, but basically, any setTimeout call stops working randomly for a few seconds or until the UI is touched when fired within a callback/listener.

For example, I have the following code:

Helper method to make all realm calls "async" (not really, but makes it awaitable and doesn't freeze the UI)

function asyncQuery(realm, cb){

  return new Promise((resolve, reject) => {
    setTimeout(() => {
      try{
        if(!realm.isInTransaction){
          resolve(cb(realm));
        }
        else{
          console.warn("Realm (query) was already in a transaction, delaying call one more loop.");
          setTimeout(()=>{
            try{
              resolve(cb(realm));
            }
            catch(err){
              reject(err);
            }
          });
        }
      }
      catch(err){
        reject(err);
      }
    })
  });
}

Listener setup

async function getQueueCollection(){
  return await asyncQuery(global.realm, (realm) => {
    return realm.objects("QueueItem");
  });
}

this.queueListener = await getQueueCollection();
this.queueListener.addListener(this.handleRealmUpdate);

Actual handler:


const countQuery = (realm, groupId) => {
  return {
    total: realm.objects('QueueItem').filtered(`groupId=${groupId}`).length,
    errors: realm.objects('QueueItem').filtered(`groupId=${groupId} AND error=true`).length
  }
}

async function count(groupId){
  return await asyncQuery(global.realm, (realm) => {
    return countQuery(realm, groupId);
  })
}

pollQueue = async (props) => {
    let user = props.user;
    if(!user){
      return;
    }

    // <<<<---- CODE FREEZES HERE
    let res = await countQueue(user.groupId);
   // <<<<<<<<<<<<<<<<<<<<<<<<<<<<<

    props.updateCounts(res.total, res.errors);
  }

handleRealmUpdate = (collection, changes) => {
    // can't be async so use promise.
    this.pollQueue(this.props).then(()=>{}).catch((err) => console.error("Failed to poll queue: ", err.message || err));
  }

@takameyer
Copy link
Contributor

It is possible usage of @realm/react would help avoid this issue. We will have to see if the problem persists after the next release.

@cristianoccazinsp
Copy link

What's the status of this? I'm seeing this with 10.20.0-beta.5 as well.

@tomduncalf
Copy link
Contributor

Fixed by #4725 #4579 #4389

@tomduncalf
Copy link
Contributor

@cristianoccazinsp Sorry for the late response here, I didn't see your comment originally, but this should be fixed in new releases. Please let us know if you think you see it occurring still

@cristianoccazinsp
Copy link

@tomduncalf thanks for the update, we will give it a try soon.

@github-actions github-actions bot locked as resolved and limited conversation to collaborators Mar 15, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.

10 participants