Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add optional key to navigate action, allowing idempotent pushes #135

Closed
Benjamin-Dobell opened this issue Feb 1, 2017 · 35 comments
Closed
Labels
Milestone

Comments

@Benjamin-Dobell
Copy link

Benjamin-Dobell commented Feb 1, 2017

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.

@Benjamin-Dobell
Copy link
Author

Benjamin-Dobell commented Feb 1, 2017

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.

@Benjamin-Dobell Benjamin-Dobell changed the title Make transitions idempotent Make navigation idempotent Feb 1, 2017
@ericvicenti
Copy link
Contributor

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
Copy link

idris commented Mar 2, 2017

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.

@ericvicenti ericvicenti changed the title Make navigation idempotent Add optional key to navigate action, allowing idempotent pushes Mar 17, 2017
@ericvicenti
Copy link
Contributor

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

@vjtc0n
Copy link

vjtc0n commented Apr 14, 2017

@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.

@prontiol
Copy link

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.

@MovingGifts
Copy link

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

@dantman
Copy link

dantman commented Apr 24, 2017

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
Copy link

ghuh commented Apr 30, 2017

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.

@satya164
Copy link
Member

satya164 commented May 3, 2017

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

@Benjamin-Dobell
Copy link
Author

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.

@dantman
Copy link

dantman commented Aug 8, 2017

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.

@rpopovici
Copy link

@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

@Benjamin-Dobell
Copy link
Author

Benjamin-Dobell commented Aug 8, 2017

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.

@dantman
Copy link

dantman commented Aug 8, 2017

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.

@Benjamin-Dobell
Copy link
Author

Benjamin-Dobell commented Aug 8, 2017

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.

@Benjamin-Dobell
Copy link
Author

Benjamin-Dobell commented Aug 8, 2017

@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.

@dantman
Copy link

dantman commented Aug 8, 2017

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.

@BrennanRoberts
Copy link

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?

@rpopovici
Copy link

rpopovici commented Aug 9, 2017

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

@adrianboimvaser
Copy link

Is there an ETA for this?

@matthamil
Copy link
Member

@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! 😄

@pinguo-pengxiaolong
Copy link

Come on, still no ETA?

@dantman
Copy link

dantman commented Nov 16, 2017

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.

@brentvatne
Copy link
Member

it's merged in #3393

satya164 added a commit that referenced this issue Feb 7, 2020

Unverified

The committer email address is not verified.
Co-Authored-By: Satyajit Sahoo <[email protected]>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

Successfully merging a pull request may close this issue.