react-navigation/react-navigation

Add optional key to navigate action, allowing idempotent pushes

Benjamin-Dobell opened this issue ยท 35 comments

Due to the fact react native generally doesn't block the native run loop, navigating to a new screen causes a state change resulting in an async render. Whilst this occurs the screen for the previous state can still be interacted with, so quickly tapping a button twice can easily cause duplicate route pushes resulting in a navigation stack that doesn't necessarily make any sense in the context of the app.

Also, because react navigation does animations in JS rather than using native transitions (iOS), user interaction is incorrectly enabled whilst rendering the new state. So even post (virtual DOM) render, during the transition the previous screen still has interaction enabled, allowing double taps as described above.

The ideal solution would be to disable interaction in response to a navigation action, however as the communication between JS-land and native-world is async, this is virtually impossible.

EDIT: Come to think of it, it's not at all impossible, it's just a bit of a pain to implement.

As such, the most logical solution is to ensure all state changes are idempotent. In the case of redux, this means that all actions should be idempotent (not just those related to navigation transitions).

Whilst the responsibility of idempotency of non-navigation actions is clearly up to app developers, react-navigation should probably ensure its own actions (and helper functions) help enforce idempotency.

In my own in-dev redux app (admittedly not thoroughly tested yet) I've implemented the following to try enforce idempotency:

Action creation helper functions:

// Although we can technically return a NavigationBackAction without a key, taking the navigation (state)
// allows us to prevent duplicate dispatches e.g. back button pressed multiple times before the transition occurs.
export function goBack(navigation: Navigation, key: ?string) : NavigationBackAction {
  return {
    type: 'Back',
    key: key === undefined ? navigation.state.key : key,
  }
}

// Nothing special about this function...
export function navigate(routeName: string, params?: NavigationParams, subrouterAction?: NavigationAction) : NavigationNavigateAction {
  return {
    type: 'Navigate',
    routeName,
    params,
    action: subrouterAction
  }
}

Navigation reducer:

// TODO: Presumably there's a more efficient way... right?
function paramsEqual(params1: ?NavigationParams, params2: ?NavigationParams) {
  if (params1 == params2) {
    return true
  }

  if (!params1 || !params2 || Object.keys(params1).length != Object.keys(params2).length) {
    return false
  }

  for (let [key, value] of Object.entries(params1)) {
    if (value !== params2[key]) {
      return false
    }
  }

  return true
}

const navigation = (state: NavigationState = initialState, action: NavigationAction) : NavigationState => {
  // Somewhat hacky way of ensuring we don't push the same route twice (quick double tap of button etc.)
  if (action.type == 'Navigate') {
    const currentRoute = state.routes[state.index]

    // TODO: Is this correct for nested navigators (action != null)?
    if (currentRoute.routeName == action.routeName && paramsEqual(currentRoute.params, action.params)) {
      return state
    }
  }

  return Navigator.router.getStateForAction(action, state)
}

As can be seen, I've not bothered pouring through react-navigation just yet in order to see how this will affect nested navigation / subrouters.

However, if my solution does make sense, then it may be worth adopting in react-navigation.

I totally agree it makes sense to make them idempotent. TabRouter should already behave this way, and we should add a test to prove it. If you want to submit a PR to make StackRouter also respect this case, I'd support it.

idris commented

Any updates on this? I'm noticing if you tap something several times to bring up a new view, it pushes that view several times.

Merging #802 here because a navigate action key would also enable replace functionality.

@ericvicenti Hi, Thank you for your supporting, but I really need a mini example how to not re-render an existent screen in stack :(

Thank you.

Any updates on this?
I believe this should be implemented in navigation lib and not in the app itself as navigating to the same route multiple times is obviously not the expected behaviour in 99% cases.

@ericvicenti How can we use replace functionality? What about the multiple tap issue this thread is about?

I wonder if there's a way react could have some sort of interaction lock() that delays to the next tick ensuring that only one user interaction is received per-tick, sets a temporary lock that blocks user interaction, and allows you to take an async action that's guaranteed to only be one action until your async action resolves.

Very crudely:

onTap() {
  const {locked} = this.state; // Can also be used in render to disable buttons, etc...
  if ( locked ) return;
  this.lock(async () => {
    return await myAction();
  });
}

Presuming that 2 taps happen in one tick and 1 more happens after the setState tick.

  • onTap is called
  • this.lock stores the callback and uses setState to set the locked state
  • onTap is called again
  • this.lock still has the last callback from this tick so the new one is discarded
  • a tick passes and the setState call goes through causing this.state to change and a render to happen
  • lock now calls its async callback from the previous tick
  • onTap is called again, locked is now true so it's exited immediately (or a the button could be disabled and not even call it)
  • myAction is called and returns a promise for something async
  • after some time the promise finally completes and lock gets the response
  • lock then uses setState to return the locked state to false

The difficult part in react-navigation would probably be providing something like async navigation actions, ie: actions that are not fire-and-forge like the current model but instead do not resolve until the navigation transition has completed. Or something similar to InteractionManager but instead waits for any queued navigation actions and transitions to complete (and ideally runs as a promise instead of a callback).

ghuh commented

Here is a crude solution to this problem if anyone needs a quick fix: #1303

I'll close out the PR when there is another solution available.

key is an interesting solution, but how would the user generate the key?

Any update on this? Started my first react-navigation project this weekend and ran into this bug very quickly. Glad to see a fix but hoping it gets accepted soon.

@rziehl I have a temporary hack/workaround to fix this issue in #1588. I'm really looking forward to this feature as well.

Debouncing the NavigationActions.navigate dispatch and triggering it on the leading edge of the timeout is an easy workaround:

import debounce from 'lodash/debounce';

const debouncedNavigateAction = debounce(
  (dispatch, routeName, params, action) =>
    dispatch(NavigationActions.navigate({
      routeName,
      params,
      action,
    })),
  // Set the wait to a reasonable duration
  1000,
  {
    trailing: false,
    leading: true,
  }
);

Indeed, if the view takes more than the wait duration to appear, the issue will still be there.

@davidbonnet do you just have that in all your views? Or is there a way to have it as an import that doesn't kill its functionality?

Any updates on this?

I implemented idempotent pushes in this PR #2334 More detailed description inside

jnbt commented

I think @rpopovici approach looks promising.

IMHO forcing people to provide custom keys and shoehorning this into dispatch is not the correct fix to this problem. The issue of button double presses is not a problem unique to dispatch, the same issue exists with other possible actions of a button such as making API calls after a button press.

The only thing we are missing in regards to the navigate double press issue is a way of knowing when react-navigation is in the middle of transitioning between screens.

Trying to solve this instead by forcing the use of customized key values only brushes the real issue aside. Not every app is covered by the "fix", there are circumstances when you can't use predictable keys as a way to work around this issue.

@dantman my proposal should work for other actions as well: back, reset(PR #2292) or set params.
Linking the dispatcher to the transitioner might introduce even more complexity to this project. What happens if you are dispatching during transition, ignore it, queue it to handle it after? Do you want multiple dispatches in one transition, or transitions for each dispatch individually. Lot's of questions but no easy solution. At least for now the solution I am proposing is fixing the most common use cases. As I have already said it the other thread, dispatching the same route multiple times in a row it's an unusual use case and it should not be the standard

Linking the dispatcher to the transitioner might introduce even more complexity to this project. What happens if you are dispatching during transition, ignore it, queue it to handle it after? Do you want multiple dispatches in one transition, or transitions for each dispatch individually. Making the dispatcher reject some dispatches is the unnecessary complexity.

I'm not saying to link either together. Double press is not a problem to be solved inside the dispatcher, double press is a problem related to the button handler. The dispatcher never gets accidental dispatches if the button handler doesn't send them.

Button handlers already have a way to reject accidental quick taps (debounce), what we need now is a way for button press handlers to reject presses when react-navigation is in the middle of transitioning to a new screen. Or a way to wait for transitioning to be over (so you can wait for it to finish before releasing your lock).

Very roughly something like:

onAButtonPress() {
  // Reject button presses that occur in between navigate and end of transition
  if ( TransitionManager.isTransitioning() ) return;

  this.props.navigation.navigate('FooBar');
}

onBButtonPress() {
  if ( this._pending ) return;

  this._pending = true;

  this.props.navigation.navigate('FooBar');

  TransitionManager.runAfterTransition(() => {
    this._pending = false;
  });
}

This is not a problem unique to the dispatcher. If react-navigation is currently leaving the current screen it's just as much of a problem if a button that triggers an API call that could affect either screen instead of a navigation dispatch is accepted. Imagine tapping the "view" button for a list item and in the middle of transitioning to that new screen the "delete" button is accidentally tapped. You're now on the view screen of an item that doesn't actually exist and the "undo" button that may disappear any second may be back on the screen you just left.

As far as I'm concerned this has absolutely nothing to do with button presses. Pushing a navigation route is possible a plethora of ways. Attempting to prevent duplicate pushes all the various ways that a navigation route can possibly be pushed sounds truly awful.

That said, I'm not indicating support for existing PR, I haven't looked it it closely. Nonetheless, idempotent pushes is clearly a good generic solution to this issue. Idempotency is a good principal to follow in software in general, where it makes sense. There is no legitimate situation ever where an app wants to push the same route twice. Yes, you may want the same screen twice, but you'll want different parameters for each screen, ergo they're not the same route.

As far as I'm concerned this has absolutely nothing to do with button presses. Pushing a navigation route is possible a plethora of ways. Attempting to prevent duplicate pushes all the various ways that a navigation route can possibly be pushed sounds truly awful.

That doesn't really change anything. Using something like TransitionManager.isTransitioning() or TransitionManager.runAfterTransition(cb) is the same whether you're using it inside an onPress callback or anything else.

The point is that it's the responsibility of the code telling react-navigation to navigate to another screen to know whether it should be changing the navigation state of the app (or making an API call). Not the responsibility of the dispatcher to try and guess whether 2 calls to dispatch were the developer intentionally telling dispatch to navigate 2 screens or 1 of them was an accidental duplicate that should have never been sent to the dispatcher.

Idempotency is a good principal to follow in software in general, where it makes sense. There is no legitimate situation ever where an app wants to push the same route twice. Yes, you may want the same screen twice, but you'll want different parameters for each screen, ergo they're not the same route.

react-navigation/react-native has no concept of idempotency in the way you are describing and can't. To know if params are the same react-navigation would have to do a deep comparison of params, something which is inherently unreliable (the moment a non-plain object like a Date or a custom class gets throw in there things fall apart) and undesirable performance wise (react-native only does shallow comparison of objects). You'd also run into false negatives โ€“ the moment you add some sort of state to the params (put a callback handler into params so you can call it from a save button in the header, take a params.id to fetch associated data from an api then use setParams to put it in params so it can be used in the header, etc...) params are no longer equal to the params you'd use when navigating to a new scene.

The PR being discussed just sets the key to the routeName (the screen's name) and forces anyone with a screen that may be used multiple times with different params to add a new custom key parameter that is different for each one.

For any app that has any screen that is used multiple times with different params, this is a breaking change. And if you also have one of those screens where it's really difficult to derive a unique key when you are trying to navigate to a new screen, you're SOL.

@Benjamin-Dobell this PR #2334 is not about double pushes. It's trying to solve the idempotency problem by defaulting the key to the route's name and allowing an optional key to be specified by the user if he wants the same transition twice in a row.
@dantman Idempotency cannot by fixed in this case if we keep the randomly generated key because they are always different than ones generated before(that was the idea). The only solution here is to have some common ground when the comparison is made and you want to return the same state for the same input params uniquely identified by a key. In your specific case, you can hash your route's params or you can use any randomly generated string to provide as an optional key. And as I have said, your app's navigation it's unusual, not the common use case

That doesn't really change anything. Using something like TransitionManager.isTransitioning() or TransitionManager.runAfterTransition(cb) is the same whether you're using it inside an onPress callback or anything else.

I think you may misunderstand this issue and/or idempotency. To implement idempotency none of what you described is necessary, I'm already doing it perfectly fine in my own app - the solution is already described in this issue, in fact in my initial comment. We're just waiting on someone (possibly myself) to submit a clean PR.

react-navigation/react-native has no concept of idempotency in the way you are describing and can't. To know if params are the same react-navigation would have to do a deep comparison of params, something which is inherently unreliable (the moment a non-plain object like a Date or a custom class gets throw in there things fall apart) and undesirable performance wise (react-native only does shallow comparison of objects).

Are your routes being persisted anywhere? Because if they're not simple serialisable data structures that isn't going to work either. My solution in my own project does deep comparison on params just fine (in addition to a route key).

Also, yes, react-navigation is idempotent (in some areas), and should be idempotent. I refer you @ericvicenti's response, the third comment in this very thread.

Also, yes, react-navigation is idempotent (in some areas), and should be idempotent. I refer you @ericvicenti's response, the third comment in this very thread.

react-navigation's current "idempotency" is different, TabNavigator and DrawerNavigator do not have a stack, they have a fixed set of items and navigate changes which one is active. StackNavigator has a stack and navigate pushes a new screen onto the end of the stack. Idempotency in tabs is a side effect of that type of navigator only ever having 1 screen per route, not an intent. react-navigation has no concept that some combination of "routeName + params == routeName + params".

Are your routes being persisted anywhere? Because if they're not simple serialisable data structures that isn't going to work either. My solution in my own project does deep comparison on params just fine (in addition to a route key).

Please see #145 as one example of people actively putting non-serializable data into params. Also you don't have to use non-serializable data to break a non-custom deep compare algorithm.

  • A dialog sets setParams({dirty: true}) when something is edited so a "discard data" dialog can be implemented. The screen now deep compares differently than the initial params you'd navigate with.
  • Multi-select code does a setParams({selectedCount: numberOfSelectedItems}) so the title can be updated, each number of selected items now deep compares differently despite being the same screen.
  • On mount you grab the id from props and fetch the data you need from your api, should you update the params with any part of this serializable data (because your title needs it to know what the screen's title should be, because one of your headerRight actions requires that data to work, etc...) your screen's params now deep compares differently to the params you would use when navigating to it.

I'm already doing it perfectly fine in my own app - the solution is already described in this issue, in fact in my initial comment.

Are you referring to comment 2 in this issue? That algorithm doesn't even do a deep compare, it's completely shallow. Before breaking on non-serializable data idempotency will disappear the moment you do something like navigation.navigate('EditSelectedItems', {ids: [1,2,3]}).


Is no-one thinking about the code that makes the call to dispatch? Some sort of handler is triggered multiple times (doesn't matter what it is, something in your code gets called twice when it should only be called once) it probably does something and then uses navigator.navigate to navigate to a new screen, but because it's called multiple times multiple screens get opened. You fix this by making the navigate action idempotent. So now everything works exactly the same, except the second navigate call becomes a no-op. But your handler still runs twice and everything else that is done in that handler is still executed, it just gets ignored as the dispatch it all leads up to never happens.
What really needs to be idempotent is the code making the navigator.navigate call, nothing in it should be double-executed.

Are you referring to comment 2 in this issue? That algorithm doesn't even do a deep compare, it's completely shallow

No, I'm not, that comment is 6 months old.

I don't know why you're opposed to idempotency, if you don't think it solves any problems, then so be it, although it does make me question how you're building your apps and APIs. Nonetheless, if implemented properly it's not going to cause any API breakage or negatively impact you or your project in any way.

If you have a problem with a particular implementation, then comment on the PR, not this issue.

@dantman By the way, I suggest looking into how React Native is implemented. It uses an asynchronous bridge between native code (UI thread) and a JavaScript environment. If you're building your app under the assumption you can stop double execution across the entirety of your app then you're fighting an impossible battle. This assumption just isn't going to hold true; you should build your app and back-end accordingly.

I know how react-native is structured, the async bridge doesn't stop you from dealing with double execution.

Unless you're redefining my use of double execution to refer to accidental additional executions of the logic inside whatever handlers you'd put a navigation.navigate call in to instead mean multiple executions of any function called by react.

From the OP:

The ideal solution would be to disable interaction in response to a navigation action, however as the communication between JS-land and native-world is async, this is virtually impossible.

EDIT: Come to think of it, it's not at all impossible, it's just a bit of a pain to implement.

Has this approach been investigated further?

Reworked my initial PR #2334 and added a second one concerning dispatch and transition synchronization #2400

  • async dispatch with callback and insured synchronicity through action queuing
  • requestAnimationFrame before dispatching in order to fix some transitioner weird behavoir where some unmounted screens get remounted for a split second while transitioning fixed in Transitioner
  • idempotent pushes for routes with identical name
  • optional unique push key. overrides route name

Is there an ETA for this?

@adrianboimvaser there are a couple of PRs open with different proposals on how to allow idempotent pushes. You are encouraged to provide your feedback in the discussion on those PRs. You are also welcome (and encouraged) to open a PR as well! ๐Ÿ˜„

Come on, still no ETA?

A few of us came to the consensus that the best technique would be to use a "source key" like how goBack works rather than adding a key property to the navigate action.
#2578 (comment)

We talked about bundling this into an RFC on new navigator actions like a newer push, replaceAt, and jump navigation actions like ex-navigation has, but I haven't had the time to write up an RFC for it.

But someone is welcome to work on writing a PR to give navigate this type of behaviour. I believe I gave enough of an explanation in how it would work for someone else to wrap their head around it and come up with an implementation.

I can't use client time to work on this, because while I agree this change is a good behaviour to give to react-navigation and this double-navigate bug does affect the app I'm working on for them, this isn't the right fix for that app. We do other things beside navigate in our actions, so we need to work on a general purpose interaction lock rather than being able to just work around that with making navigator.navigate idempotent.

it's merged in #3393