-
-
Notifications
You must be signed in to change notification settings - Fork 656
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
Conversation
Thanks! Discussed this in chat.
|
4cc1a95
to
6e4b4d0
Compare
Thanks for the review and many suggestions, @gnprice! In this revision, the main upgrade commit is considerably smaller. |
There was a problem hiding this 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.
componentDidMount() { | ||
this.fetch(); | ||
} |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
src/chat/ChatScreen.js
Outdated
// Second `useEffect` (order matters) | ||
React.useEffect( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think these two useEffect
s, 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
There was a problem hiding this comment.
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.
if (isFocused === true) { | ||
this.fetch(); | ||
} else { | ||
this.shouldFetchWhenNextFocused = true; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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(() => { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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 PureComponent
s 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.
There was a problem hiding this comment.
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 ascomponentDidMount
andcomponentDidUpdate
. However, we recommend starting with useEffect first and only tryinguseLayoutEffect
if that causes a problem.
There was a problem hiding this comment.
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 await
ed 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.
There was a problem hiding this comment.
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.
src/boot/AppDataFetcher.js
Outdated
@@ -15,13 +15,13 @@ type Props = $ReadOnly<{| | |||
|}>; | |||
|
|||
class AppDataFetcher extends PureComponent<Props> { | |||
componentDidUpdate = () => { | |||
componentDidUpdate() { |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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!
src/ZulipMobile.js
Outdated
</AppDataFetcher> | ||
</AppEventHandlers> | ||
<HideIfNotHydrated PlaceholderComponent={LoadingScreen}> | ||
<AppEventHandlers> |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
src/account/accountsSelectors.js
Outdated
@@ -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( |
There was a problem hiding this comment.
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 anA
value to it, which would break its being aB[]
. So the type-operator[]
akaArray
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 getB
values, and those are fineA
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.
There was a problem hiding this comment.
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! 🙂
src/nav/NavigationService.js
Outdated
assertReady(); | ||
// $FlowFixMe - `current` will not be null. | ||
return appContainerRef.current.dispatch(navigationAction); |
There was a problem hiding this comment.
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);
src/nav/NavigationService.js
Outdated
@@ -16,19 +16,23 @@ const appContainerRef = React.createRef< | |||
NavigationContainerProps<{ ... }, NavigationState>>> | |||
>(); | |||
|
|||
const getState = (): NavigationState => { | |||
const assertReady = () => { |
There was a problem hiding this comment.
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?: {| |
There was a problem hiding this comment.
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.)
There was a problem hiding this comment.
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 |}
.)
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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: comparingnavigateToRealmScreen
and thisnavReducer
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
.
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)
…ject. As Greg mentions [1], it's helpful to see right alongside the definition whether something is exported or not. [1] zulip#4300 (comment)
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)
…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)
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)
6e4b4d0
to
faaba6c
Compare
Thanks for the review, @gnprice! I've pushed a new revision that responds to several comments, and I've got a few replies for you: |
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)
…ject. As Greg mentions [1], it's helpful to see right alongside the definition whether something is exported or not. [1] zulip#4300 (comment)
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)
…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)
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)
faaba6c
to
71c6ff6
Compare
(Just fixed some conflicts.) |
|
||
React.useEffect(() => { |
There was a problem hiding this comment.
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.
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.
c284544
to
761b763
Compare
Thanks for the revision! Looks great -- merged. |
Great, thanks! Just sent #4393 for the rest. |
Fixes[edit: now the first portion of a fix for] #4296Here, 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?