mobxjs / mobx-state-tree

Full-featured reactive state management without the boilerplate
https://mobx-state-tree.js.org/
MIT License
6.99k stars 641 forks source link

EAS Build outputs different bundled code for preview build #2220

Closed alexvcasillas closed 1 week ago

alexvcasillas commented 2 weeks ago

Hey @coolsoftwaretyler , as we spoke in the X thread and by your suggestion I'm opening up this issue on the MST repo though it might not even be related to MST itself, but I hope that you can help me try to catch up what's happening 🤔

I have the following user.store.ts

export const User = types
  .model('User', {
    id: types.identifier,
    dbId: types.maybeNull(types.number),
    appleIdentityToken: types.maybeNull(types.string),
    googleAccountSub: types.maybeNull(types.string),
    username: types.maybeNull(types.string),
    picture: types.maybeNull(types.string),
    title: types.maybeNull(types.string),
    email: types.maybeNull(types.string),
    stats: types.maybeNull(types.late((): IAnyModelType => Stats)),
    achievements: types.maybeNull(types.late((): IAnyModelType => Achievements)),
    purchasedItems: types.maybeNull(types.late((): IAnyModelType => PurchasedItems)),
    unlockedItems: types.maybeNull(types.late((): IAnyModelType => UnlockedItems)),
    gamesPlayed: types.maybeNull(types.late((): IAnyModelType => GamesPlayed)),
    signInMethod: types.maybeNull(types.enumeration(['apple', 'google'])),
    // Loading UI State
    loading: false,
  })
  .views((self) => ({
    get authenticated() {
      if (!Network.isConnected) return false;

      return self.dbId !== null;
    },
  }))
  .actions((self) => ({
    afterCreate: flow(function* () {
      self.loading = true;

      const storedAppleCredential: AppleAuthentication.AppleAuthenticationCredential | null =
        yield getStoredAppleCredentials();

      const storedGoogleCredential: GoogleCredentials | null = yield getStoredGoogleCredentials();

      if (storedAppleCredential || storedGoogleCredential) {
        let user: any;
        let signInMethod: 'apple' | 'google' | null = null;

        if (storedAppleCredential) {
          user = yield getUserByAppleIdentityToken(storedAppleCredential.user);
          signInMethod = 'apple';
        } else if (storedGoogleCredential) {
          user = yield getUserByGoogleAccountSub(storedGoogleCredential.user.id);
          signInMethod = 'google';
        }

        if (user) {
          self.setAuthenticatedUser(user);
          self.signInMethod = signInMethod;
          self.gamesPlayed = GamesPlayed.create();
          self.stats = Stats.create();
          self.achievements = Achievements.create();

          self.purchasedItems = PurchasedItems.create();
          self.unlockedItems = UnlockedItems.create();

          // We need to delay this notification as it seems
          // to be called before the UI is ready
          setTimeout(() => {
            showNotification({
              title: i18n.t.welcome.hiUserWelcomeTitle.replace('{{username}}', self.username),
              description: i18n.t.welcome.hiUserWelcomeDescription,
              colors: { title: 'yellow', description: 'white' },
              offsetTop: 20,
            });
          }, 1000);
        }
      } else {
        // We need to create the gamesPlayed object no matter
        // if there's an authenticated user
        self.gamesPlayed = GamesPlayed.create();
        self.stats = Stats.create();
        self.achievements = Achievements.create();
      }

      self.loading = false;
    }),
  }))
  .create({
    id: 'user',
  });

const UserStoreContext = createContext<null | Instance<typeof User>>(User);

export const UserStore = ({ children }) => {
  return <UserStoreContext.Provider value={User}>{children}</UserStoreContext.Provider>;
};

export function useUser() {
  const store = useContext(UserStoreContext);

  if (store === null) {
    throw new Error('Store cannot be null, please add a context provider');
  }

  return store;
}

export interface IUser extends Instance<typeof User> {}
export interface IUserSnapshotIn extends SnapshotIn<typeof User> {}
export interface IUserSnapshotOut extends SnapshotOut<typeof User> {}

getStoredAppleCredentials is just a wrapper for a call to await SecureStore.getValueFor and the same for getStoredGoogleCredentials

I have omitted most of the actions and views for the sake of the example.

Then I have this stats.store.ts

export const Stats = types
  .model('Stats', {
    level: 1,
    experience: 0,
    gamesPlayed: 0,
    touchedPieces: 0,
    createdAt: types.maybeNull(types.Date),
    updatedAt: types.maybeNull(types.Date),
    deletedAt: types.maybeNull(types.Date),
  })
  .views((self) => ({
    get user(): IUser {
      return getParent(self);
    },
  }))
  .actions((self) => ({
    afterAttach: flow(function* () {
      const storedStats = storage.getString('@polyhedricom:stats');

      if (self.user.authenticated) {
        const stats = yield getStats(self.user.dbId);

        if (stats) {
          self.level = stats.level;
          self.experience = stats.experience;
          self.gamesPlayed = stats.gamesPlayed;
          self.touchedPieces = stats.touchedPieces;
        }
      } else {
        const storedStats = storage.getString('@polyhedricom:stats');

        if (storedStats) {
          applySnapshot(self, JSON.parse(storedStats));
        }
      }
    }),
  }));

export interface IStats extends Instance<typeof Stats> {}

Again some actions and views have been omitted for the sake of the example.

When the app launches, I use in one of my observer components the useUser() hook, which as there's no session stored that can be retrieved from the getStoredAppleCredentials and getStoredGoogleCredentials the flow goes into the else which does:

// We need to create the gamesPlayed object no matter
// if there's an authenticated user
self.gamesPlayed = GamesPlayed.create();
self.stats = Stats.create();
self.achievements = Achievements.create();

But none of these are actually created and therefore they're using the default null as per the type definition on the User model.

The issue that I'm facing here is that this actually works in a development environment as the GamesPlayed.create(), Stats.create(), and Achievements.create() return their respective default values that are set in their respective models.

I'm starting to think that this might be related to something along the build process with MMKV or even after introducing Sentry but since the code that does not get executed lives within MST it could also mean that it's been treated somehow differently when build with EAS for the preview environment 🤔

coolsoftwaretyler commented 2 weeks ago

Thanks @alexvcasillas! I'll take a look later this week or over the weekend.

I have a suspicion this isn't related to MST, but this issue will help me improve my confidence in that assessment.

alexvcasillas commented 2 weeks ago

Thanks @alexvcasillas! I'll take a look later this week or over the weekend.

I have a suspicion this isn't related to MST, but this issue will help me improve my confidence in that assessment.

Thank you, Tyler! I'm investigating and researching about it so I can release the game so I'll come back here to share my findings :)

coolsoftwaretyler commented 1 week ago

Hey @alexvcasillas - reading through your code now and my first question is: do you have any try/catch logic on the afterCreate hook for User?

yield can throw, and if you're not catching it, I don't think either of your if / else statements will evaluate. That might be the culprit. Especially if you're getting some kind of Apple credentials and seeing failures in production but not development.

Consider this code, where we throw from an async function and neither branch evaluates:

import { flow, types } from "mobx-state-tree";

async function getStoredCredentials() {
  throw new Error("Did not work");
}

(async function () {
  const User = types
    .model("User", {
      name: "Default value",
    })
    .actions((self) => ({
      afterCreate: flow(function* () {
        const credentials = yield getStoredCredentials();

        if (credentials) {
          self.name = "It worked";
        } else {
          self.name = "Call worked but was falsy";
        }
      }),
    }));

  const user = User.create();

  // This will log out "Default Value" - neither branch evaluated
  console.log(user.name);
})();

See in CodeSandbox

If you're not catching errors, maybe start there and do some logging in production.

I'll keep thinking and try to build a better reproduction or test case later this week or next, but hopefully this is helpful.

alexvcasillas commented 1 week ago

Hey @alexvcasillas - reading through your code now and my first question is: do you have any try/catch logic on the afterCreate hook for User?

yield can throw, and if you're not catching it, I don't think either of your if / else statements will evaluate. That might be the culprit. Especially if you're getting some kind of Apple credentials and seeing failures in production but not development.

Consider this code, where we throw from an async function and neither branch evaluates:

import { flow, types } from "mobx-state-tree";

async function getStoredCredentials() {
  throw new Error("Did not work");
}

(async function () {
  const User = types
    .model("User", {
      name: "Default value",
    })
    .actions((self) => ({
      afterCreate: flow(function* () {
        const credentials = yield getStoredCredentials();

        if (credentials) {
          self.name = "It worked";
        } else {
          self.name = "Call worked but was falsy";
        }
      }),
    }));

  const user = User.create();

  // This will log out "Default Value" - neither branch evaluated
  console.log(user.name);
})();

See in CodeSandbox

If you're not catching errors, maybe start there and do some logging in production.

I'll keep thinking and try to build a better reproduction or test case later this week or next, but hopefully this is helpful.

Hey @coolsoftwaretyler I don't have any try-catch blocks on the afterCreate logic as most of the asynchronous calls are API calls or Secure Storage calls. I've upgraded to the latest MST 7.0 and upgraded some other dependencies and the app now works in the preview but I'm hesitant to consider this done. I'll wrap the async calls within try-catch to try to handle everything properly just in case it's something that's throwing and I'm not aware of

coolsoftwaretyler commented 1 week ago

@alexvcasillas - yeah I think it makes sense to at least try and eliminate it as a possibility. While you're adding exception handling I might recommend some logging in the function as well to really see what behavior it's exhibiting in production.

coolsoftwaretyler commented 1 week ago

Alex and I spoke in a DM and he said the root of this issue might be with Stripe. Closing.