Closed kavimuru closed 2 years ago
Triggered auto assignment to @puneetlath (Engineering
), see https://stackoverflow.com/c/expensify/questions/4319 for more details.
Is this only happening on mobile web and no other platform?
Bump on that question ^ @kavimuru.
Also, the end of that video is really fast. It's hard for me to tell what is going on.
@puneetlath When the tester taps back button it takes user to previous page of the chrome. It happens only with chrome browser not in safari. Web, apps takes user to chats list page.
.
Got it thanks!
Triggered auto assignment to @michaelhaxhiu (External
), see https://stackoverflow.com/c/expensify/questions/8582 for more details.
Upwork job link - https://www.upwork.com/jobs/~019f9c795e01626473
Triggered auto assignment to Contributor-plus team member for initial proposal review - @parasharrajat (Exported
)
Triggered auto assignment to @puneetlath (Exported
), see https://stackoverflow.com/c/expensify/questions/7972 for more details.
Job value moving up to $500 since 1 week and no takers.
Current assignee @parasharrajat is eligible for the Exported assigner, not assigning anyone new.
Triggered auto assignment to @iwiznia (Exported
), see https://stackoverflow.com/c/expensify/questions/7972 for more details.
@iwiznia I'm about to head out on my two-month sabbatical, so handing the CME for this issue off to you. Thanks!
Doubling price to $1000.
Doubling price to $2000.
Doubling price to $4000.
@iwiznia do you agree with doubling this again?
I guess so
Doubling price to $8000
Doubling price to $16000
Doubling again to $32,000 big ones!
Adding more context to this issue. This may be related.
When you open a tab
and visit any url in which the RHN
is opened and click on the backdrop it gets it back to browser home page.
Thank you @mdneyazahmad
Solution
When search page is opened and we select the searched user from search page we dont dismiss the modal,
if we dismiss the modal when user from search page is selected it fixes the issues.
Also we have to apply setTimeout on this line of code Navigation.navigate(ROUTES.getReportRoute(option.reportID));.
Pressing back from browser was navigating to browser start page.
What is fixed
It fixes the issue of opening start page of chrome
It fixes the issue just mentioned above by @mdneyazahmad when LHN is openend and backdrop is clicked.
It fixes the issue when back button of browser is pressed from new chat page.
So by applying this fix all the issues happing on Mweb, or normal web are fixed.
The code change is in the SearchPage.js
in selectReport function. The code change is
if (option.reportID) {
Navigation.dismissModal() // new line to dismiss modal
this.setState({
searchValue: '',
}, () => {
// setTimeout is applied on navigation.navigate
setTimeout(() => {
Navigation.navigate(ROUTES.getReportRoute(option.reportID));
}, 10)
});
} else {
Report.fetchOrCreateChatReport([
this.props.session.email,
option.login,
]);
}
I'm not sure about the setTimeout thing without it fix is not working. I guess SetTimeout is needed so that the actions or api calls in background are executed before this Navigation.navigate(ROUTES.getReportRoute(option.reportID)); is executed.
After the fix is applied (Note in last i'm pressing browser back button which is navigating fine.
Web after the fix is applied.
cc: @michaelhaxhiu
Thanks for the proposal @aneequeahmad.
So when search page is opened and we select report from search page we dont dismiss the modal so if we dismiss the modal when report is selected it fixes the issues also we have to apply setTimeout on this line of code
I do not agree with this statement. We close the modal from the navigation.navigate
function. How do you think the modal is closed on clicking on any report from the search Page?
Could you please use bullet points to explain all the issues you are talking about?
You are vouching for the use of SetTimeout
but settimeout
are never considered as a solution. But if you think this is the only way, Could you please raise this in Slack and get others' opinions?
@parasharrajat
I dont see any Navigation.dismissModal() in navigation.navigate
as the following code is executed on select report from search page
if (isDrawerRoute(route)) {
navigationRef.current.dispatch(CustomActions.pushDrawerRoute(route));
return;
}
I've updated the comment above with bullet points.
Yes, but without setTimeout things are not working. This is the only way I've debugged thoroughly but only setTimeout is fixing the issue as i mentioned the cause in my opinion. Raising it in Slack for others opinions.
There are three scenarios where this issue seems to occur.
The issue seems to stem from a bug inside the react-navigation
library itself. It is related to how deep links get handled in react-navigation and there are a couple of open issues in their repo which already discuss this bug. To verify that the bug is indeed caused due to this, just remove linking prop from below code block and test the scenarios above.
Both scenarios 1 & 2 should pass. We cannot replicate 3, as removing linking config also removes nested URL navigation.
This points to a bug within the linking implementation of react-navigation library. Whenever we navigate directly to a nested route and try to go back to a parent path (Home in our case), it simply exits the app.
Diving deeper I traced the issue back to useLinking.tsx
When we navigate to a new path inside the app, onStateChange listener inside useLinking gets invoked. it tries to keep the app's internal state in sync with browser history. The listener compares current state with previous state to figure out whether user moved forward or backward in history. If user is going to a new path (historyDelta>0), it pushes a new path to history.
If user goes to a previous path then historyDelta becomes negative. Then it tries to find the backIndex
from its available local history and navigates to the previous path index if the path is found in memory. If path is not found in local state it tries to navigate back in history byhistoryDelta
steps.
Everything sounds reasonable until this point, except that when directly navigating to a nested URL and then going back, there is no previous history for current app in browser and it would simply exit the app. The code seems to be missing some logic to guard against this scenario.
Going inside history.go
implementation we could see this line which notes that We shouldn't go back more than the 0 index (otherwise we'll exit the page)
. There also seems to be a condition which tries to guard this state
index = n < 0 ? Math.max(index - n, 0) : Math.min(index + n, items.length - 1);
Math.max(index-n,0)
works as long as index is greater than n and n is positive. Once n becomes negative index - (negativeN)
becomes positive and the index gets assigned to a value higher than intended. This seems to be a bug. Quick thought was to add a condition which should prevent such a scenario.
if(index+n<0){
return
}
That’s it!. Now everything works as expected. To verify it locally modify the file
node_modules/@react-navigation/native/lib/module/useLinking.js
Modify line 131 as shown below and test all the three scenarios mentioned above. It should all pass.
if (n === 0 || index+n<0) {
return;
}
Since this bug seems to originate outside of Expensify need raise a PR with the above fix over at @react-navigation and wait for it to get merged. Meanwhile we could create a patch package and add it to post install step. This works both for local development and CI environments.
https://user-images.githubusercontent.com/6880914/164079545-cb8efbc2-aec5-4e9b-996c-fdb62ef75896.mp4
Lurking here just out of curiosity (I clicked a link on a marketing email from Expensify that linked to this issue) - @aswin-s 's thorough explanation was really cool to read. If his proposal looks good, an alternative upstream fix could also be the more elegant:
index = Math.min(Math.max(index + n, 0), items.length - 1);
I think it should also work with browser and android back button.
@aswin-s very well explained and awesome work here
Thanks for the proposal @aswin-s. Reviewing it ~shortly~ in a few hours.
I was wondering if https://github.com/Expensify/App/issues/4612 might be fixed as well with the proposed solution. I will test it later.
Glad to see a detailed post.
Math.max(index-n,0) works as long as index is greater than n and n is positive. Once n becomes negative index - (negativeN) becomes positive and the index gets assigned to a value higher than intended. This seems to be a bug. Quick thought was to add a condition which should prevent such a scenario.
I am trying to understand this. It seems wrong to me. I hope you can help me understand it better.
index = n < 0 ? Math.max(index - n, 0) : Math.min(index + n, items.length - 1);
based on this line Math.max(index-n,0)
only runs when n < 0(negative) so this works as long as index is greater than n and n is positive.
statement does not seem correct. When N is positive, it will never run.
Maybe you can provide more samples to verify this. I think it would be better to see what is the index value when this issue occurs.
I will test your proposal. If it is fixing the issue then great but we need to find what is really the root cause of the issue. Index as you say or something else.
@parasharrajat Let me elaborate it a bit more.
n : no of steps to go forward or backward in history
items : An array of history records in the format [{ id, state, path } ].
It keeps track of paths the user has navigated until this point, corresponding state-Id in window.history.state and the
navigational state for that path
index : current index in local history.items
While the issue occurs, state of things are as follows
n = -1
index = 0
items=[
{
"path": "/settings",
"state": {
"stale": false,
"type": "stack",
"key": "stack-v4lUoHF8I9ZEZtEvxa20A",
"index": 1,
"routeNames": [
"Home",
"ValidateLogin",
..
],
"routes": [
{
"name": "Home",
"key": "Home-GbYcmWDMwtZrWtQ66YUkQ",
"state": {
…
}
},
{
"name": "Settings",
"state": {
..
},
"key": "Settings-BWJWNNnPrxgG846fZkOxK"
}
]
},
"id": "ZrPdc2T6l7gk5kg0aXwvs"
}
]
So when the resultant index gets calculated by this logic
index = n < 0 ? Math.max(index - n, 0) : Math.min(index + n, items.length - 1);
Math.max(0-(-1),0)
index becomes 1. But items.length is just 1
. So index could never be greater than zero.
And finally navigation happens on line 196 using the value of n
itself. So just fixing the index value might not be enough like @izhan suggested. Please correct me if I'm wrong.
window.history.go(n);
OK, got it. Now the question is what is an acceptable solution to this bug. Let me do some testing on my end.
So just fixing the index value might not be enough.
Why?
Why
Finally redirection happens on like 196 using value of n
, which is -1. index
is calculated to map history to proper internal state. So even if we fix index and set it to zero, browser will still navigate to previous path which is outside the app.
I have got the root cause of the this issue and one more earlier posted by me https://github.com/Expensify/App/issues/7618#issuecomment-1101334527 But, I do not have a solid proposal to fix this issue yet. Posting soon. Thank you!
@aswin-s gotcha.
Figured out a way to fix this issue within Expensify codebase itself. It's basically just reversing the logic thats happening inside linking library.
When we navigate to ROUTES.HOME
, DrawerActions.openDrawer()
gets called which removes the last drawer state from history. So when onStateChange
listener in useLinking gets fired, it sees that there is a route removed from history and it needs to go one step back in browser history and calls history.go(-1)
To avoids this right before opening the drawer, push a drawer state with status as 'open' right before root path. This would keep the history length intact even after openDrawer action and browser will never go back in history.
To achieve this modify navigate method in Navigation.js
https://github.com/Expensify/App/blob/0d97ece7fe548eb00468245674f341850879b06c/src/libs/Navigation/Navigation.js#L122-L131
like this
function navigate(route = ROUTES.HOME) {
if (!canNavigate('navigate', {route})) {
return;
}
if (route === ROUTES.HOME) {
if (isLoggedIn) {
const state = navigationRef.current.getState();
const newState = {
...state,
routes: _.map(state.routes, (r) => {
if (r.state.type === 'drawer' && r.state.history.length === 2) {
const [firstRoute, lastRoute] = r.state.history;
const history = [firstRoute,
// Insert a history record to indicate drawer is open
{type: 'drawer', status: 'open'},
lastRoute];
return {...r, state: {...r.state, history}};
}
return r;
}),
};
navigationRef.current.dispatch(CommonActions.reset(newState));
// useLinking library will recognise the state changes only in the next tick, so we need a timeout
setTimeout(openDrawer, 0);
return;
}
This solves scenarios 1 & 2 mentioned above. We still need another fix to handle navigating to deep linked URLs like /settings
. The most easiest way to fix this is to push root path to history whenever previous state in browser history is not set by linking library. This could be identified by the presence of window.history.state.id.
Linking.getInitialURL()
.then((url) => {
if (Platform.OS === 'web' && !window.history.state) {
window.history.pushState({
id: SCREENS.HOME,
}, null, '/');
}
});
This change fixes scenario 3 .
Fix the underlying issue, so the inner state of react navigation and browser history are in sync automatically.
In useLinking.js
, change const historyDelta = ...
to let historyDelta = ...
.
Below it, add:
// If we go back on one stack and, at the same time, forward on another stack, only the first action gets processed.
// Guard against this by using a middle version of the state, i.e. state adjusted by the first action.
// Then, compare it to the the final state.
if (historyDelta < 0 && previousFocusedState.index + historyDelta >= 0) {
const middleIndex = previousFocusedState.index + historyDelta;
const previousdRoutes = previousFocusedState.history || previousFocusedState.routes || [];
if (middleIndex < previousdRoutes.length) {
const middleState = {
...previousFocusedState,
index: middleIndex,
history: undefined,
routes: previousdRoutes.slice(0, middleIndex + 1),
}
const [middleFocusedState, nextFocusedState] = findMatchingState(middleState, state);
if (middleFocusedState && nextFocusedState) {
historyDelta +=
(nextFocusedState.history ? nextFocusedState.history.length : nextFocusedState.routes.length) -
(middleFocusedState.history ? middleFocusedState.history.length : middleFocusedState.routes.length);
}
}
}
In CustomActions.js
, change the return statement of pushDrawerRoute
to:
const drawerKey = navigationRef.current.getRootState().routes[0].state.key; // TODO: Replace by a constnat
return CommonActions.reset({
...state,
routes: [{
key: newScreenName,
name: newScreenName,
params: newScreenParams,
}],
history,
routeNames: [newScreenName],
default: 'open',
key: drawerKey,
index: 0,
stale: false,
type: 'drawer',
});
It solves the 2 issues mentioned above. Dismissing the settings dialog when opening the URL is unrelated. Also, it would probably be better to dismiss it without depending on fake history steps. At least the effort indicated in the codebase comments is to preserve real history to the user.
I'll extend the description in the upcoming days.
We are performing two navigations here:
Unfortunately react-navigation has some...quirks...especially when you are navigating both UP and DOWN in one React render cycle.
What happens when a normal navigation occurs:
setState
occurs on the state, with the newest state object in BaseNavigationContainer
https://github.com/react-navigation/react-navigation/blob/main/packages/core/src/BaseNavigationContainer.tsx#L98useEffect
runs, which emits a "state"
event with the latest updated state https://github.com/react-navigation/react-navigation/blob/a937523d59301013fabe872516da61f0cd5b304c/packages/core/src/BaseNavigationContainer.tsx#L370useLinking
listens to the state
event here https://github.com/react-navigation/react-navigation/blob/main/packages/native/src/useLinking.tsx#L617 and runs onStateChange
onStateChange
finds the difference between the two states https://github.com/react-navigation/react-navigation/blob/a937523d59301013fabe872516da61f0cd5b304c/packages/native/src/useLinking.tsx#L557 via findMatchingState
and uses that to calculate what history to change, i.e. the historyDelta
. Note that findMatchingState
only compares ONE level deep.When you do a normal navigation, everything works as intended. There is typically only one routes
or history
that has changed, and step 4 is able to compare the two states and figure out the difference. Note that the implementation in step 4 only does one history change, i.e. a single push / replace, etc
What's happening here The issue here is that when we dispatch two navigations at once, we end up with state objects with not one but TWO changes. Here's what the state object looked like before
and after
Why is onStateChange
running not with two updates, but one single merged update? It's because we dispatched these two navigations synchronously. Despite both states being updated in step 1 (the setState), step 2 (the useEffect
hook) was only run ONCE for both of the changes because useEffect asynchronous and tied to the React render cycle.
Then as the merged states marches on to step 4, since onStateChange
only shallowly compares differences, it only knows to call history.go(-1)
and doesn't realize we need to push the drawer close event onto the history stack as well. Thus, when we try to navigate back, we're missing that extra entry, and instead of undoing the drawer close event, we instead navigate back to the Chrome homepage (or whatever website you were on before this)
We can change node_modules/@react-navigation/native/lib/module/useLinking.js
to recursively look for changes in the route changes. Currently, onStateChange
only looks at one layer of history changes but we can recursively let it call itself on children to continue looking for more changes.
Why recursively? While this bug is only one level deep (Home > Search, Home > Route), one can imagine having multiple nested navigations to handle.
We'll want to change onStateChange
in https://github.com/react-navigation/react-navigation/blob/a937523d59301013fabe872516da61f0cd5b304c/packages/native/src/useLinking.tsx#L531 with
const onStateChange = async () => {
const navigation = ref.current;
if (!navigation || !enabled) {
return;
}
const previousState = previousStateRef.current;
const state = navigation.getRootState();
// root state may not available, for example when root navigators switch inside the container
if (!state) {
return;
}
const pendingPath = pendingPopStatePathRef.current;
const route = findFocusedRoute(state);
const path = getPathForRoute(route, state);
previousStateRef.current = state;
pendingPopStatePathRef.current = undefined;
// To detect the kind of state change, we need to:
// - Find the common focused navigation state in previous and current state
// - If only the route keys changed, compare history/routes.length to check if we go back/forward/replace
// - If no common focused navigation state found, it's a replace
let [previousFocusedState, focusedState] = findMatchingState(
previousState,
state
);
while (previousFocusedState && focusedState) {
if (
// We should only handle push/pop if path changed from what was in last `popstate`
// Otherwise it's likely a change triggered by `popstate`
path !== pendingPath
) {
const historyDelta =
(focusedState.history
? focusedState.history.length
: focusedState.routes.length) -
(previousFocusedState.history
? previousFocusedState.history.length
: previousFocusedState.routes.length);
if (historyDelta > 0) {
// If history length is increased, we should pushState
// Note that path might not actually change here, for example, drawer open should pushState
history.push({ path, state });
} else if (historyDelta < 0) {
// If history length is decreased, i.e. entries were removed, we want to go back
const nextIndex = history.backIndex({ path });
const currentIndex = history.index;
try {
if (nextIndex !== -1 && nextIndex < currentIndex) {
// An existing entry for this path exists and it's less than current index, go back to that
await history.go(nextIndex - currentIndex);
} else {
// We couldn't find an existing entry to go back to, so we'll go back by the delta
// This won't be correct if multiple routes were pushed in one go before
// Usually this shouldn't happen and this is a fallback for that
await history.go(historyDelta);
}
// Store the updated state as well as fix the path if incorrect
history.replace({ path, state });
} catch (e) {
// The navigation was interrupted
}
} else {
// If history length is unchanged, we want to replaceState
history.replace({ path, state });
}
} else {
// If no common navigation state was found, assume it's a replace
// This would happen if the user did a reset/conditionally changed navigators
history.replace({ path, state });
}
const prevChildState = previousFocusedState.routes[focusedState.index].state;
const childState = focusedState.routes[focusedState.index].state;
[previousFocusedState, focusedState] = findMatchingState(
prevChildState,
childState
);
}
};
It looks like a lot of code, but all I'm really doing is just refactoring / indenting code. The main change is the while loop and the extra findMatchingState
call that continues searching for changes. If that solution looks ok, we'll need to update it in the react-navigation repo and do whatever patches needed on this end.
Solution 1 will work, but as noted in https://github.com/react-navigation/react-navigation/blob/main/packages/native/src/useLinking.tsx#L614 - the maintainers may not want us to be updating more than once. To solve this correctly, react-navigation may require a ton of rewrites, especially step 2's useEffect
only-running-once issue. I looked into refactoring it, but I didn't see any quick wins 😄
So although I wouldn't recommend it, we can add a setTimeout
to break up the navigateBackToRootDrawer()
and CommonActions.reset(...)
updates into two render cycles. That way, the useEffect
runs once for each update. Again I wouldn't recommend it, but it would be a fix if there is no movement in the react-navigation repo. If you want me to outline what that would look like, I can – it's somewhat trivial.
ha @marktoman great minds think alike! looks like you beat me to posting a solution as I was still writing it up
+1 to @marktoman's comment that the modal issue should be treated separately
According to what the developer comments suggest for this line of code it should be taking care of any value which is 0 or lesser than 0 where as it just matches to the value 0 only. Which is probably turning out to be buggy in our case. I've highlighted the from and to code for quick reference.
node_modules/@react-navigation/native/lib/module/useLinking.js - Line 131
And this is the expected behavior in the attached video link. https://user-images.githubusercontent.com/72865619/164605632-d60a5320-6d13-42c8-9a61-649b35174d6e.mov
Ahhhh finally using setTimeout
makes sense to someone hehe. @izhan look at my proposal here i suggested it already. it fixes all 3 solutions but before setTimeout, we have to dismiss the modal too and yeah i was curious if n<0 was not handeled in react-navigation
how does my solution was working.
@izhan Ha, and it took me forever to hit send because of re-testing. Nice write-up, you explain it really well.
One way to treat the changes to react navigation is to keep them in the Expensify codebase by intent.
The pros:
Generally, the cons are that the upstream repo changes as well. In the case of useLinking
, however, the code was written in a single bout of effort some two years ago and has not been touched ever since. In either case, this update is an insertion rather than a throughout modification.
Another thing is the compat package that Expenisfy uses for linking config. If they do a major rewrite of useLinking
, it is very unlikely that they bring the compat features alongside it. I expect that adapting this update to a potential new version would be a lot less effort then replacing the linking config system.
The mature patching tooling makes this approach a viable option in my opinion.
Thanks for the investigation, everyone. I am following each post and I will share the reviews asap.
in App/src/libs/Navigation/CustomActions.js :
import navigationRef from './navigationRef';
+ import getPlatform from '../getPlatform';
+ import CONST from '../../CONST';
...
- function navigateBackToRootDrawer() {
+ function navigateBackToRootDrawer(newScreenParams) {
const activeState = getActiveState();
// To navigate to the main drawer Route, pop to the first route on the Root Stack Navigator as the main drawer is always the first route that is activated.
// It will pop all fullscreen and RHN modals that are over the main drawer.
// It won't work when the main drawer is not the first route of the Root Stack Navigator which is not the case ATM.
navigationRef.current.dispatch({
...StackActions.popToTop(),
target: activeState.key,
});
+ const routeName = navigationRef.current.getState().routes[1].name;
+ if (getPlatform() === CONST.PLATFORM.WEB && ['Search', 'NewGroup', 'NewChat', 'IOU_Request', 'IOU_Bill', +'IOU_Send'].includes(routeName)) {
+ window.history.pushState({}, '', `r/${newScreenParams.reportID}`);
+ }
}
...
if (currentState.type !== 'drawer') {
- navigateBackToRootDrawer();
+ navigateBackToRootDrawer(newScreenParams);
Result
@gustavo-3 This one looks hacky. I don't think it is a good idea to be creating an if
condition for each page.
@tomivs
you can remove this if
and the code still works fine but will face another issue when you try go back from Settings -->
About --> Report a bug
Could you please stop evaluating proposals? there is someone already reviewing these.
Great proposals overall. At least we all agree that this is the issue with react-navigation.
I am reviewing each proposal one by one now. There are a few quick eliminations for proposals.
@mdneyazahmad No solution yet.
@aneequeahmad I think root cause analysis is wrong. No preferable solution. No points backing the solution. I'm not sure about the setTimeout thing without it fix is not working.
@mujtabasac Feels duplicate.
@gustavo-3 As others suggested the problem is with react-navigation. No proper explanation of the solution. Direct manipulation of history API even though we use react-navigation
to do that for us.
Thank you for your proposals. Let us know if you have better solutions.
we would prefer if this is fixed upstream. Although, I agree that patching would be best to fast forward the fix on the App.
I would appreciate it if we keep the discussion positive, and ethical, and give constructive criticism.
If you haven’t already, check out our contributing guidelines for onboarding and email contributors@expensify.com to request to join our Slack channel!
Action Performed:
Expected Result:
User returns to the Chats window, list of chats is displayed after tapping the Back button on the chat window
Actual Result:
Chrome start page loaded after tapping Back button on chat window. The issue occurs with a new chat. The issue doesn't occur with existing chats. The same issue with a new group chat.
Workaround:
Unknown
Platform:
Where is this issue occurring?
Version Number: 1.1.37-0 Reproducible in staging?: Y Reproducible in production?: Y Logs: https://stackoverflow.com/c/expensify/questions/4856 **Notes/
https://user-images.githubusercontent.com/43996225/152889875-f6ebc564-9377-4a1b-b8b3-c382d8ff3e2c.mp4
Photos/Videos:**
Upwork job link: https://www.upwork.com/jobs/~019f9c795e01626473 Issue reported by: Applause
Slack conversation:
View all open jobs on GitHub