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

React Navigation v5 upgrade (part 1) #4300

Merged
merged 34 commits into from
Jan 9, 2021

Conversation

chrisbobbe
Copy link
Contributor

@chrisbobbe chrisbobbe commented Nov 3, 2020

Fixes [edit: now the first portion of a fix for] #4296

Here, you can see how gigantic the main upgrade commit is—do you think it would be good to split it up, and how might be a good way to do that, I wonder?

@chrisbobbe chrisbobbe marked this pull request as draft November 3, 2020 22:14
@gnprice
Copy link
Member

gnprice commented Nov 5, 2020

Thanks!

Discussed this in chat.

We've discussed some ideas [...] and I think the picture is quite encouraging! It seems like we'll be able to make this upgrade pretty clean.

@chrisbobbe chrisbobbe marked this pull request as ready for review November 13, 2020 22:18
@chrisbobbe chrisbobbe changed the title [DRAFT] React Navigation v5 upgrade. React Navigation v5 upgrade. Nov 13, 2020
@chrisbobbe
Copy link
Contributor Author

Thanks for the review and many suggestions, @gnprice! In this revision, the main upgrade commit is considerably smaller.

Copy link
Member

@gnprice gnprice left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the revisions! I like how much smaller that main upgrade commit is now 😄

Comments below on the first N commits of the branch. I read this far in today's session:
11d1dc8 types: Remove a dubious type.
872cc5b MessageReactionList [nfc]: Remove an already-done TODO.
ae9529c SharingScreen: Rename tab routes.
8e82bb2 ChatScreen: Use React Hooks.
6207f3f AppDataFetcher: Check needsInitialFetch on componentDidMount, too.
d712b28 AppDataFetcher: Make componentDidUpdate an instance method.
2f673b1 AppDataFetcher: Use React Hooks.
c07bb6e ZulipMobile: Don't show most of the component tree until done hydrating.
431cb5e InitialNavigationDispatcher [nfc]: Factor out getInitialRouteInfo.
10b7361 ZulipMobile: Set initial route declaratively; remove "initial navigation".
4704df7 accountsSelectors types: Change a $ReadOnlyArray to a regular array.
ab40b24 actionTypes [nfc]: Stop re-exporting a type from React Navigation.
11dd3dc NavigationService [nfc]: Take readiness assertion out into a helper function.
a3bf821 nav types [nfc]: Add src/nav/globalTypes, to be used soon.
11e5774 RealmScreen types: Mark that navigation.state.params will be present.

(plus looked ahead a bit, particularly to see the shrinkage of that main commit 😉 .) I think most of these will be good to merge eagerly, even as we're still working on the later portion of the branch.

Still to review:

e8e7f9f screen types [nfc]: Rewrite navigation type for screens on AppNavigator.
f2d9044 screen types [nfc]: Rewrite navigation type for screens on MainTabs.
c61b4ce screen types [nfc]: Rewrite navigation type for screens on StreamTabs.
ea703f4 screen types [nfc]: Rewrite navigation type for screens on SharingScreen.
b704740 MessageReactionList: Pass initialRouteName, even if undefined.
c339e91 MessageReactionList [nfc]: Replace getReactionsTabs with two functions.
74775f3 ZulipAppContainer: Pass theme prop to AppContainer.
c263c1e nav [nfc]: Remove some soon-to-be-unnecessary comments.
377a1a3 deps: Add libdefs for new, differently named React Navigation packages.
9a7a128 deps: Upgrade to React Navigation v5.
a759f8c deps: Clear out old libdefs for react-navigation.
f81a45d MessageReactionList [nfc]: Inline some helper functions.
5f203d3 navActions: Stop using @react-navigation/compat.
19167f6 ChatScreen: Stop using @react-navigation/compat.
43f1bd9 MainTabs: Stop using @react-navigation/compat.
96e11bf AppNavigator: Stop using @react-navigation/compat.
1252aa8 SharingScreen: Stop using @react-navigation/compat.
9037717 StreamTabs: Stop using @react-navigation/compat.
6e4b4d0 deps: Remove @react-navigation/compat.

Comment on lines -78 to -80
componentDidMount() {
this.fetch();
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It looks like the old code will unconditionally fetch on first mount; and if I'm reading right, the new code won't -- it'll only fetch when first focused. Does that seem right to you?

I think ideally we'd have the existing behavior: fetch immediately on first mount. If this component got mounted at all, then the user will surely want to see it soon and we might as well get started on fetching. Can we maintain that?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Aha -- I think we can do this by translating the componentDidMount like so:

React.useEffect(
  () => {
    fetch();
  },
  [],
);

Got that from the note here in the useEffect reference doc.

Well, except I think that will cause two fetches on first mount, if it happens that isFocused is already true.

Ah, but we can prevent that: put this useEffect in between the other two, and have it set shouldFetchWhenNextFocused.current = false. Or perhaps better yet, push that latter line inside fetch, since both its callsites will be doing it.

Copy link
Contributor Author

@chrisbobbe chrisbobbe Nov 16, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, good points! I've responded in chat.

Comment on lines 98 to 99
// Second `useEffect` (order matters)
React.useEffect(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think these two useEffects, and the associated state, would be a great candidate to pull out as a separate function. The call site might look like:

const fetchError = useFetchMessages({ eventQueueId, isFocused, narrow, dispatch });

The implementation would have shouldFetchWhenNextFocused as its own local variable (with the same React.useRef call), and make the React.useState call for fetchError and both calls to React.useEffect.

See discussion here: https://reactjs.org/docs/hooks-custom.html

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good idea! I've responded in chat, here.

Comment on lines -88 to -91
if (isFocused === true) {
this.fetch();
} else {
this.shouldFetchWhenNextFocused = true;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The old code at this spot is unnecessarily tricky, as I stare at it again: instead of this conditional, it could just unconditionally set this.shouldFetchWhenNextFocused = true.

Then the code just below here would see that, and if isFocused were indeed true, it'd go and do a fetch, and set this.shouldFetchWhenNextFocused back to false. That'd be simpler -- and also closer to what the hooks version ends up looking like. So if you do that as a prep commit, then it'll be a simplification in itself and also make it easier to reason about the refactor to hooks.

Hmm in fact: there's one case where that'd change the behavior -- and I think it fixes a subtle bug. If should-fetch is already true, and then this gets called with a(nother) change to eventQueueId at a time when we're focused... then the existing code will go and fetch, and leave should-fetch true. So then if we get unfocused and focused again, we'll fetch a second time, even if the queue hasn't changed. The hooks change fixes that, and so would the simplification that drops this conditional.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for catching this!


React.useEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The code for determining when `doInitialFetch` needs to run is now
more compact and easier to follow, as long as we're OK working with
React Hooks. I don't believe any functional changes are instrumental
to the upcoming upgrade to React Navigation 5.

I'm not sure I follow the last bit. Do you think this is NFC? It looks like it should be. If not, it'd be educational to discuss what does change.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

One thing I'm not so sure about is how shouldComponentUpdate and PureComponents get translated to Hooks.

One thing we could use, in the direction of getting PureComponent's optimizations, is React.memo. While PureComponent does a shallow comparison of both props and state, React.memo only shallowly compares props. Not using React.memo, I believe, is comparable to using Component instead of PureComponent.

Can you think of any advantage we've been currently getting by using PureComponent for AppDataFetcher? I'm a little stuck on that question when I look at our pattern of using children as a render prop. My first thought is that we're probably not getting much of an advantage, if any. But you're right, it would be educational to find out.

Copy link
Contributor Author

@chrisbobbe chrisbobbe Nov 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how the following translates to React Native, but in React on the Web, useEffect is unlike componentDidMount in that it fires after the browser has painted. To avoid this change, the thing to use is useLayoutEffect, but the docs specifically recommend trying useEffect first:

If you’re migrating code from a class component, note useLayoutEffect fires in the same phase as componentDidMount and componentDidUpdate. However, we recommend starting with useEffect first and only trying useLayoutEffect if that causes a problem.

Copy link
Contributor Author

@chrisbobbe chrisbobbe Nov 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looking through a TL;DR on a blog post by Dan Abramov on useEffect, it seems smart to generally assume there will be functional changes when you convert a class component to one that uses Hooks.

But I think we've avoided the most prominent among them: the "capturing" of props and state from the body of the component function. If, in the useEffect, we awaited some asynchronous thing, and then after that, we read some values from props or state, we would risk those values being stale. But we don't do such a thing.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I replied in chat, to make more room and because this seems like a discussion with reusable interest.

@@ -15,13 +15,13 @@ type Props = $ReadOnly<{|
|}>;

class AppDataFetcher extends PureComponent<Props> {
componentDidUpdate = () => {
componentDidUpdate() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is NFC, right? If so, helpful to say that in the commit message; if not, I'm curious about how 🙂

Also:

AppDataFetcher: Make `componentDidUpdate` an instance method.

I think "prototype method" would be clearer. The thing it was, in the old code, is a property on each instance, so it's confusing to have "instance method" mean the thing that contrasts with that. The thing it becomes in this new code is a property on the prototype.

I'm not aware of a canonical standard pair of terms for this distinction, but I got "prototype method" from the MDN "Classes" article, and it fits well.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right, makes sense! Thanks!

</AppDataFetcher>
</AppEventHandlers>
<HideIfNotHydrated PlaceholderComponent={LoadingScreen}>
<AppEventHandlers>
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

- We attach a few event listeners in `AppEventHandlers` for some
  graphical concerns: handling orientation changes and setting the
  safe-area insets. These should be fine to be delayed as long as
  the component we show during the delay (`LoadingScreen`) is very
  simple, which it is.

Yeah. Hmm, let's see if we can pin down what it means to be simple that way: looking closely at AppEventHandlers, what it actually does with either of those is to dispatch some Redux actions, to store the orientation or the safe-area insets into Redux. So as long as LoadingScreen doesn't try to go get those from Redux, it can't be affected.

Ooh, but in fact it does. Specifically it invokes ZulipStatusBar, which does, like so:

export default connect<SelectorProps, _, _>((state, props) => ({
  safeAreaInsets: getSession(state).safeAreaInsets,
  theme: getSettings(state).theme,
  narrowTitleBackgroundColor: getTitleBackgroundColor(state, props.narrow),
  orientation: getSession(state).orientation,
}))(ZulipStatusBar);

Now, I'm not sure that logic is right. See #3066. But it will potentially be affected.

Copy link
Contributor Author

@chrisbobbe chrisbobbe Nov 17, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, right. Hmm. So, during rehydration, the default values will be used. A particular problem is that the safe-area insets default to zero. (That doesn't matter when the orientation is landscape, since the status bar isn't shown and the safe-area insets are ignored. But the default value for the orientation is portrait.)

We currently use react-native-safe-area to asynchronously get the safe-area insets on startup and put them in Redux.

I wonder about using the much-more-popular react-native-safe-area-context (already in our dependencies) instead. Once you've set up a SafeAreaProvider, it looks like you can subscribe any component to the safe-area insets using a Hook, a HOC, or a Context consumer. This seems a more natural approach than depending on a dispatch to Redux, maybe? And it looks like react-native-safe-area doesn't have something like this.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder about using the much-more-popular react-native-safe-area-context (already in our dependencies) instead.

Just opened #4315 for this.

@@ -14,7 +14,7 @@ export type AccountStatus = {| ...Identity, isLoggedIn: boolean |};
* This should be used in preference to `getAccounts` where we don't
* actually need the API keys, but just need to know whether we have them.
*/
export const getAccountStatuses: Selector<$ReadOnlyArray<AccountStatus>> = createSelector(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm! If this array really can't be read-only, then that's potentially a sign of something wrong someplace that consumes this data. So worth a quick investigation.

If at the tip of this branch, I edit this back to $ReadOnlyArray, I get this error:

Error ┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈┈ src/account/AccountPickScreen.js:84:16

Cannot call connect because read-only array type [1] is incompatible with array
type [2] in property accounts of type argument T.

     src/account/AccountPickScreen.js
 [2] 19│   accounts: AccountStatus[],
       :
     81│   }
     82│ }
     83│
     84│ export default connect(state => ({
     85│   accounts: getAccountStatuses(state),
     86│   hasAuth: hasAuth(state),
     87│ }))(AccountPickScreen);
     88│

     src/account/accountsSelectors.js
 [1] 17│ export const getAccountStatuses: Selector<$ReadOnlyArray<AccountStatus>> = createSelector(

Changing that array type to read-only too, I get a similar error at the next hop; changing that one too, the chain terminates. So this diff fixes things:

diff --git a/src/account/AccountList.js b/src/account/AccountList.js
index 01ef1585a..4a6aa78d0 100644
--- a/src/account/AccountList.js
+++ b/src/account/AccountList.js
@@ -9,3 +9,3 @@ import AccountItem from './AccountItem';
 type Props = $ReadOnly<{|
-  accounts: AccountStatus[],
+  accounts: $ReadOnlyArray<AccountStatus>,
   onAccountSelect: number => void,
diff --git a/src/account/AccountPickScreen.js b/src/account/AccountPickScreen.js
index 0af0b3b8f..48e4e5eb2 100644
--- a/src/account/AccountPickScreen.js
+++ b/src/account/AccountPickScreen.js
@@ -18,3 +18,3 @@ type Props = $ReadOnly<{|
 
-  accounts: AccountStatus[],
+  accounts: $ReadOnlyArray<AccountStatus>,
   dispatch: Dispatch,

In general we should probably have a lot more array types marked as read-only than we do. So when there's a mismatch like this, my preference is to fix it by expanding what's marked that way rather than shrinking it.

In addition to catching if we accidentally pass a value somewhere that could mutate it, the core thing that marking as read-only does for us is it makes the type covariant.

  • If you wanted an array of some base type A and what you have is an array of a subtype B, that won't do -- the code that wanted A[] could expect to be able to add an A value to it, which would break its being a B[]. So the type-operator [] aka Array is "invariant".
  • But if you only wanted a $ReadOnlyArray<A>, then a $ReadOnlyArray<B> is perfectly fine: the code that wanted the $ReadOnlyArray<A> might read things from it and it'll get B values, and those are fine A values. So B a subtype of A means $ReadOnlyArray<B> is a subtype of $ReadOnlyArray<A>; in other words, $ReadOnlyArray is "covariant".

So for example a GravatarURL[] couldn't be passed to a function that wanted AvatarURL[]; but if the function's parameter type is marked read-only, then a $ReadOnlyArray<GravatarURL> could be passed to it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, right, thanks for all that detail! 🙂

Comment on lines 34 to 36
assertReady();
// $FlowFixMe - `current` will not be null.
return appContainerRef.current.dispatch(navigationAction);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, a shame to have to have the fixme there.

One way to avoid that -- a way to make it transparent to Flow what's happening -- is to have the helper with the assertions also return the container value:

const getContainer = () => {
  if (navigationContainerRef.current === null) {
    throw new Error('Tried to use NavigationService before `navigationContainerRef` was set.');
  }
  if (isReadyRef.current !== true) {
    throw new Error('Tried to use NavigationService before `NavigationContainer` was ready.');
  }
  return navigationContainerRef.current;
};

Then the two callers nicely simplify, too:

const getState = (): PossiblyStaleNavigationState => getContainer().getRootState();

const dispatch = (navigationAction: GenericNavigationAction): void => getContainer().dispatch(navigationAction);

@@ -16,19 +16,23 @@ const appContainerRef = React.createRef<
NavigationContainerProps<{ ... }, NavigationState>>>
>();

const getState = (): NavigationState => {
const assertReady = () => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm -- can we turn this from export default { foo, bar } style to export const foo = …; export const bar = … style?

The main nice thing that'd give us is that it's manifest at the definition whether it's going to be exported or not -- which helps for understanding the intended interface.

A small bonus is that if you hit Shift+F12 in VS Code on one of these definitions now, to find where it's used, it'll only show you references within the module. Not too bad, because you can go and hit Shift+F12 again over at one of those references -- the export default -- and find all the other places it's used. But it'd be nicer to be able to see that in one hop; and it's also potentially misleading to see references only within the module, as it's not necessarily obvious that's what's happening.

To keep the call sites unchanged like NavigationService.dispatch, I think all that's needed is to say import * as NavigationService rather than import NavigationService. See e.g. 3a6abb3, where I made a similar change for exampleData.

@@ -24,7 +24,7 @@ type Props = $ReadOnly<{|
// `navigation` prop for free, with the stack-nav shape.
navigation: NavigationStackProp<{|
...NavigationStateRoute,
params?: {|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Before a770a348d, there was an easily overlooked case where `params`
would be missing: although we included `params` whenever we
dispatched an action to navigate to the 'realm' route, there was
still a case in `navReducer` in which the nav state was constructed
with the 'realm' route active but with no params.

I think this isn't actually true -- though the code was certainly tricky. We had return getStateForRoute('realm');... but then that function's definition had this:

 /**
  * Get the initial state for the given route.
  *
  * Private; exported only for tests.
  */
 export const getStateForRoute = (route: string): NavigationState => {
   // TODO: this is kind of a hack!  Refactor to a better way.
   //  * Perhaps pass `initial: true` unconditionally in this initialization
   //    code, to all routes?  Then that'd just be part of the interface of
   //    making a screen work as an initial screen.
   //  * Alternatively, we could replace the whole system of `ModalNavBar`,
   //    `canGoBack`, etc., with our own "navigation view" that would be more
   //    directly integrated into the navigation framework:
   //      https://reactnavigation.org/docs/en/2.x/navigation-views.html
   const params = route === 'realm' ? { initial: true } : undefined;

So in fact when we went to the 'realm' route via the logic in navReducer, we'd still pass a params object.

(Hmm, and I have the feeling that we've had this conversation before, sometime in the last few weeks. Am I imagining that? The lesson is probably that upon discussing it then, it would have been smart to go promptly edit the code to make it clearer -- with a comment over on the RealmScreen side, if nothing else, but better by clarifying the actual code.)

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Also the type of that params object isn't quite right: comparing navigateToRealmScreen and this navReducer hack, it should be {| realm?: URL | void, initial: boolean | void |}.)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Hmm, and I have the feeling that we've had this conversation before, sometime in the last few weeks. Am I imagining that? The lesson is probably that upon discussing it then, it would have been smart to go promptly edit the code to make it clearer -- with a comment over on the RealmScreen side, if nothing else, but better by clarifying the actual code.)

Ah, right—that conversation was over at #4273 (comment). It looks like (even in a34d3ba) I skipped right over your observation in that discussion that params would always be present, oops. I have a vague memory that I had actually encountered an error while testing the branch, which prompted me to remove 6f6a3ee from it (and comment at #4273 (comment)), and that might have led me to skip over that part. I guess, if there was an error, it was about something else, or else the navReducer code just jumped out at me as a potential reason for params to be missing, even if that turned out to be wrong.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(Also the type of that params object isn't quite right: comparing navigateToRealmScreen and this navReducer hack, it should be {| realm?: URL | void, initial: boolean | void |}.)

Hmm, now that navReducer is gone and we're using navigateToRealmScreen and resetToRealmScreen, I think it should be {| realm: URL | void, initial: boolean | void |}, maybe? In both those functions, if the caller doesn't pass realm or initial, it ends up being present in the action as undefined.

@gnprice
Copy link
Member

gnprice commented Nov 14, 2020

I've gone and merged these two commits from early in the branch, as they're completely ready and also aren't closely related to the upgrade:
33de8a4 MessageReactionList [nfc]: Remove an already-done TODO.
5fb6057 SharingScreen: Rename tab routes.

chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Nov 17, 2020
react-native-safe-area is getting a bit old; it's now been two years
since the last change in the GitHub repo
(miyabi/react-native-safe-area). And, according to NPM,
react-native-safe-area-context has almost 300,000 weekly
downloads [1], to react-native-safe-area's 7,000 [2].

This more popular library gives us a built-in way to consume the
safe-area insets, so that we don't have to go through Redux.
Actually, three ways: a HOC, a React Context consumer, and a React
Hook. The inclusion of the Hook, in particular, signals that this
library is still maintained and forward-looking. See discussion [3]
for one reason we might prefer to stop using Redux to handle the
safe-area insets.

We installed it in f3b6c1f to satisfy peer-dependency constraints,
but at that point we hadn't been planning on actually using it. So,
now, make a libdef for it (using Flowgen) and add the
`SafeAreaProvider` to our various wrappers in src/ZulipMobile.js.

[1] https://www.npmjs.com/package/react-native-safe-area-context
[2] https://www.npmjs.com/package/react-native-safe-area
[3] zulip#4300 (comment)
chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Nov 17, 2020
…ject.

As Greg mentions [1], it's helpful to see right alongside the
definition whether something is exported or not.

[1] zulip#4300 (comment)
chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Nov 17, 2020
Greg points out that this code has been unnecessarily tricky [1]:

"""
The old code at this spot is unnecessarily tricky, as I stare at it
again: instead of this conditional, it could just unconditionally
set `this.shouldFetchWhenNextFocused = true`.

Then the code just below here would see that, and if `isFocused`
were indeed true, it'd go and do a fetch, and set
`this.shouldFetchWhenNextFocused` back to false. That'd be simpler
-- and also closer to what the hooks version ends up looking like.
So if you do that as a prep commit, then it'll be a simplification
in itself and also make it easier to reason about the refactor to
hooks.
"""

He also notices a bug that this fixes:

"""
If should-fetch is already true, and then this gets called with
a(nother) change to `eventQueueId` at a time when we're focused...
then the existing code will go and fetch, and *leave* should-fetch
true. So then if we get unfocused and focused again, we'll fetch a
second time, even if the queue hasn't changed.
"""

[1] zulip#4300 (comment)
chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Nov 17, 2020
…spots.

Greg points out [1] that we should probably have more array types
marked as read-only than we do.

Marking these particular ones means we avoid a Flow error in an
upcoming commit.

[1] zulip#4300 (comment)
chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Nov 17, 2020
We only navigate to the 'realm' route with `navigateToRealmScreen`
and `resetToRealmScreen`, and in both cases, we pass `params`.

See discussion [1] for more history of this.

[1] zulip#4300 (comment)
chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Nov 17, 2020
react-native-safe-area is getting a bit old; it's now been two years
since the last change in the GitHub repo
(miyabi/react-native-safe-area). And, according to NPM,
react-native-safe-area-context has almost 300,000 weekly
downloads [1], to react-native-safe-area's 7,000 [2].

This more popular library gives us a built-in way to consume the
safe-area insets, so that we don't have to go through Redux.
Actually, three ways: a HOC, a React Context consumer, and a React
Hook. The inclusion of the Hook, in particular, signals that this
library is still maintained and forward-looking. See discussion [3]
for one reason we might prefer to stop using Redux to handle the
safe-area insets.

We installed it in f3b6c1f to satisfy peer-dependency constraints,
but at that point we hadn't been planning on actually using it. So,
now, make a libdef for it (using Flowgen) and add the
`SafeAreaProvider` to our various wrappers in src/ZulipMobile.js.

[1] https://www.npmjs.com/package/react-native-safe-area-context
[2] https://www.npmjs.com/package/react-native-safe-area
[3] zulip#4300 (comment)
chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Dec 31, 2020
…ject.

As Greg mentions [1], it's helpful to see right alongside the
definition whether something is exported or not.

[1] zulip#4300 (comment)
chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Dec 31, 2020
Greg points out that this code has been unnecessarily tricky [1]:

"""
The old code at this spot is unnecessarily tricky, as I stare at it
again: instead of this conditional, it could just unconditionally
set `this.shouldFetchWhenNextFocused = true`.

Then the code just below here would see that, and if `isFocused`
were indeed true, it'd go and do a fetch, and set
`this.shouldFetchWhenNextFocused` back to false. That'd be simpler
-- and also closer to what the hooks version ends up looking like.
So if you do that as a prep commit, then it'll be a simplification
in itself and also make it easier to reason about the refactor to
hooks.
"""

He also notices a bug that this fixes:

"""
If should-fetch is already true, and then this gets called with
a(nother) change to `eventQueueId` at a time when we're focused...
then the existing code will go and fetch, and *leave* should-fetch
true. So then if we get unfocused and focused again, we'll fetch a
second time, even if the queue hasn't changed.
"""

[1] zulip#4300 (comment)
chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Dec 31, 2020
…spots.

Greg points out [1] that we should probably have more array types
marked as read-only than we do.

Marking these particular ones means we avoid a Flow error in an
upcoming commit.

[1] zulip#4300 (comment)
chrisbobbe added a commit to chrisbobbe/zulip-mobile that referenced this pull request Dec 31, 2020
We only navigate to the 'realm' route with `navigateToRealmScreen`
and `resetToRealmScreen`, and in both cases, we pass `params`.

See discussion [1] for more history of this.

[1] zulip#4300 (comment)
@chrisbobbe
Copy link
Contributor Author

(Just fixed some conflicts.)

src/types.js Outdated Show resolved Hide resolved

React.useEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I replied in chat, to make more room and because this seems like a discussion with reusable interest.

gnprice pushed a commit to gnprice/zulip-mobile that referenced this pull request Jan 4, 2021
Greg points out that this code has been unnecessarily tricky [1]:

"""
The old code at this spot is unnecessarily tricky, as I stare at it
again: instead of this conditional, it could just unconditionally
set `this.shouldFetchWhenNextFocused = true`.

Then the code just below here would see that, and if `isFocused`
were indeed true, it'd go and do a fetch, and set
`this.shouldFetchWhenNextFocused` back to false. That'd be simpler
-- and also closer to what the hooks version ends up looking like.
So if you do that as a prep commit, then it'll be a simplification
in itself and also make it easier to reason about the refactor to
hooks.
"""

He also notices a bug that this fixes:

"""
If should-fetch is already true, and then this gets called with
a(nother) change to `eventQueueId` at a time when we're focused...
then the existing code will go and fetch, and *leave* should-fetch
true. So then if we get unfocused and focused again, we'll fetch a
second time, even if the queue hasn't changed.
"""

[1] zulip#4300 (comment)
Instead, use the `useDispatch` and `useSelector` hooks.
Before this, a `doInitialFetch` would not have been dispatched in
cases where `needsInitialFetch` was `true` from the very beginning
(in `componentDidMount`). Now, it will get dispatched in those
cases.

In an upcoming commit, such a case will exist.
As we always do for the React lifecycle methods.
The code for determining when `doInitialFetch` needs to run is now
more compact and easier to follow, as long as we're OK working with
React Hooks.

Mostly NFC; see discussion [1] for a few points that we might have
to worry about elsewhere but don't have to here.

[1] https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/converting.20to.20Hooks/near/1092349
…ing.

See discussion [1] for more on the choice to hide React Navigation's
`AppContainer` until we're done hydrating, and to do so with
something like the `LoadingScreen`. We just reuse the
`LoadingScreen` and adjust its type appropriately.

Here, hide `AppContainer` and more -- all the way up through
`AppEventHandlers`. It seems good to be able to guarantee to those
components that, by the time they've mounted, they can have all the
data from rehydration, if they want it.

A few points on the question of whether this delay in mounting will
be harmful to any of the logic in these components:

- Their appearance already follows a delay, and a pretty arbitrary
  one at that: React Native has always taken some amount of time
  after the app has launched before it decides it's ready to show
  any of our React components. This isn't always just a few
  milliseconds, either; while debugging, it includes the many
  seconds it takes to download the JS bundle.

- For how we react to an "initial notification" that opened the app,
  it should still work to go and ask for that notification's data
  when we want to ask for it, as we do in
  `handleInitialNotification` in `AppEventHandlers` -- we're not
  constrained by any particular schedule there, as far as I can
  tell. In fact, the real work here is already being postponed until
  after rehydration; this is because `narrowToNotification` is a
  (thunk) action, and so it gets held in a queue by
  redux-action-buffer until we've rehydrated.

- We attach a few event listeners in `AppEventHandlers` for some
  graphical concerns: handling orientation changes and setting the
  safe-area insets. These should be fine to be delayed as long as
  the component we show during the delay, `LoadingScreen`, doesn't
  depend on these listeners having been set. `LoadingScreen` *does*
  consume `state.session.orientation` and
  `state.session.safeAreaInsets`, which are set from these
  listeners. There's no harm in using the default value of
  "portrait" for orientation, but the default value for the
  safe-area insets isn't useful, so that is a potential concern.
  zulip#4315 would resolve it by not storing the safe-area insets in
  Redux.

- There are a few other listeners that get set. One
  suspicious-looking thing is the `state === 'active'` condition in
  `handleAppStateChange` in `AppEventHandlers`. Are we looking at an
  attempt to set a listener just in time to catch the app's launch
  (to the 'active' state) from being totally closed? No:
  command-clicking through, the instructions in
  `notificationOnAppActive` are meant to be run upon going to the
  active state from the *background* state, not the inactive state.
  So it's fine to delay setting that listener.

[1] https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/React.20Navigation.20v5/near/1039507
We're about to change the initial-route logic, and it'll be
convenient for this to be in its own file when we do that.

See discussion [1].

[1] https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/React.20Navigation.20v5.3A.20createAppNavigator/near/1058043
In a recent commit, we ensured that this component (at its single
intended callsite) is not mounted until the store has hydrated,
meaning that the value of the `isHydrated` prop would always be
true.

So, simplify by removing the conditionals that expected it to ever
be false.
…ents.

Soon, we'll introduce a `ZulipAppContainer` component that will wrap
the `AppContainer` component (i.e., the component returned by
react-navigation's `createAppContainer`) while doing the job of
`InitialNavigationDispatcher` (though in a different way).

So, start by combining `AppContainer` and
`InitialNavigationDispatcher` into the same file, named for that
future component.
Keeping (for now) the values that had been used before, i.e.,
'loading' for `initialRouteName` and nothing for
`initialRouteParams`.

`AppNavigator` is now computed at the top level of
ZulipAppContainer.js, rather than AppNavigator.js. In an upcoming
commit, we'll further defer the computation to within the
constructor of a new React component, `ZulipAppContainer`.

The awkwardness of doing that will disappear in the upcoming React
Navigation v5 upgrade, in which the new component-based API will do
away with `create*` functions in favor of plain React components to
be invoked in JSX.
And, within it, encapsulate the setting of the special `ref`
attribute to `NavigationService.appContainerRef`.

That interface change is made for convenience. I found that using
`React.forwardRef` to expose that capability to the caller would
have made it harder to do something unrelated, but that we'd like to
do soon.

(In particular, to call `createAppContainer` once, and in the right
time and place. We consider the appropriate pattern to be calling it
in `ZulipAppContainer`'s constructor and storing the result as an
instance property. But `React.forwardRef` takes a render function,
and a render function can't adequately describe `ZulipAppContainer`
while `ZulipAppContainer` depends on using an instance property.
"But wait," you might say, "that's not true! What about Hooks-based
components? After all, a Hooks-based component itself is basically a
glorified render function!" True, but there's a catch. The drop-in
equivalent for instance properties in Hooks-based components,
according to React [1], is the `useRef` hook. While they've
probabaly chosen the right name for that, I think it would just be
too confusing for us to use `useRef` alongside something we've used
just as rarely, with the very similar name `forwardRef`.)

[1] https://reactjs.org/docs/hooks-faq.html#is-there-something-like-instance-variables
As foreshadowed in a recent commit, the call to `createAppContainer`
is no longer in `ZulipAppContainer`'s toplevel, but rather in the
constructor of the `ZulipAppContainer` React component, and we store
it in an instance property on the component.

The difference is that now, at its current callsite, it's possible
to be aware of the things held in Redux that would allow us to set
`initialRouteName` and `initialRouteParams`, appropriately, before
any navigation config is established. This will let us remove the
awkward `InitialNavigationDispatcher` component and get us using the
`react-navigation`-appropriate levers for choosing the initial route
and params.
…ion".

In a recent commit, we ensured that none of React Navigation's
components get mounted until the store has rehydrated. The data from
rehydration is just what we need to tell React Navigation what the
"initial route" is. With that data, we can remove the awkward
`InitialNavigationDispatcher` logic, which would be difficult to
coordinate with the `NavigationService`'s initialization schedule in
React Navigation 5 [1], which we hope to upgrade to soon.

The mechanics of initializing the root navigator will be simpler
with React Navigation v5's component-based API. The initial route
name and params will just be passed as props to the appropriate
components; there isn't the kind of static configuration necessary
in React Navigation 4 via `createStackNavigator`.

[1] https://reactnavigation.org/docs/navigating-without-navigation-prop/#handling-initialization
…spots.

Greg points out [1] that we should probably have more array types
marked as read-only than we do.

Marking these particular ones means we avoid a Flow error in an
upcoming commit.

[1] zulip#4300 (comment)
Just import straight from 'react-navigation' when we need it -- why
not?
The React Navigation v5 doc on type checking with TypeScript (they
don't have one for Flow, even though there are pretty good libdefs
on FlowTyped) [1] says that we should maintain a global "param list"
type, which will merge the param lists of all of our navigators.

After the upgrade, we're likely to somewhat rearrange our types; in
particular, we might like to define the params for a screen
alongside the screen's own definition [2]. But we're going through
the upgrade itself according to the docs, where the docs are
reasonably clear [3].

[1] https://reactnavigation.org/docs/typescript
[2] https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/React.20Navigation.20v5.3A.20param.20types/near/1057942
[3] https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/React.20Navigation.20v5.3A.20param.20types/near/1057980
We make sure `navigateToRealmScreen` passes `params`, and that
`params` is included for the 'realm' route in `getInitialRouteInfo`.

See discussion [1] for more history of this.

[1] zulip#4300 (comment)
The type for these params is the following:

```
    params: {|
      realm: URL | void,
      // ...
    |},
```

So, technically, `realm` should be present as `undefined` instead of
missing.
`navigateToRealmScreen` sets `undefined` for the `initial` param; it
doesn't leave it out, even if the caller doesn't specify what they
want for `initial`.

Also, `getInitialRouteInfo` never has `initial` missing in the
params for the 'realm' route.
@gnprice gnprice changed the title React Navigation v5 upgrade. React Navigation v5 upgrade (part 1) Jan 9, 2021
@gnprice gnprice merged commit 761b763 into zulip:master Jan 9, 2021
@gnprice
Copy link
Member

gnprice commented Jan 9, 2021

Thanks for the revision! Looks great -- merged.

@chrisbobbe
Copy link
Contributor Author

Thanks for the revision! Looks great -- merged.

Great, thanks! Just sent #4393 for the rest.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants