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

Fix idempotent pushes during transition. #2578

Closed
wants to merge 2 commits into from
Closed

Fix idempotent pushes during transition. #2578

wants to merge 2 commits into from

Conversation

davepack
Copy link
Member

@davepack davepack commented Sep 13, 2017

This fixes #135. This solution attempts to make idempotent pushes automatic by setting nav state from within Transitioner when a transition starts and ends. While inTransition === true, getStateForAction in StackRouter will ignore NAVIGATE, BACK, and RESET actions.

Several different solutions have been proposed for this issue, this attempts to be zero work for devs to get it working, nail the transition timing, and also keep logic and flow simple.

@@ -9,6 +9,8 @@ const INIT = 'Navigation/INIT';
const NAVIGATE = 'Navigation/NAVIGATE';
const RESET = 'Navigation/RESET';
const SET_PARAMS = 'Navigation/SET_PARAMS';
const SET_TRANSITION_END = 'Navigation/SET_TRANSITION_END';

This comment was marked as abuse.

This comment was marked as abuse.

@@ -141,6 +141,48 @@ export default (
};
}

// params can be set while in transition

This comment was marked as abuse.

This comment was marked as abuse.

@@ -141,6 +141,48 @@ export default (
};
}

// params can be set while in transition

This comment was marked as abuse.

@rpopovici
Copy link

rpopovici commented Sep 15, 2017

@davepack there is an alternative solution to idempotent pushes in #2334. IMO, your solution it's good but it's introducing a lot of complexity to the existing code and it's basically a time based solution. Idempotent functions are not time based and should not have a time variable.

@rpopovici
Copy link

@davepack and also, more important, now you are tight coupling the router to the Transitioner. I think they are suppose to be weak coupled. The router should reflect the route state not the transition state. The Transitioner should be smart enough to figure it out what's next not the other way around. My two cents..

@rpopovici
Copy link

And of course it's not react's way to do it like this. The state should flow in one direction alone, from parent to child

@rpopovici
Copy link

If I understand correctly, you intend to ignore any dispatched action if you are in transition. How I am suppose to know when a transition is ended?!? because there is no callback fired when dispatch finishes. Right! dispatch is suppose to be synchronous and to return immediately the next state. It was designed like this in order to play nice with redux. With your solution I will not be able to navigate programmatically because I don't know when a transition is finished unless I tap into the onTransitionStart onTransitionEnd events. I recommend to take a look at #1206. There's a long talk in there about these issues

@davepack
Copy link
Member Author

davepack commented Sep 15, 2017

Hi @rpopovici, thanks for weighing in, your solution was one I looked at while coming up with this solution.

it's introducing a lot of complexity to the existing code

I feel like this solution is less complex since there are fewer control flow statements and less repetition. It feels easier to understand the flow and what is happening. But obviously we'll both be biased to our own solutions as far as what is easier to understand :-)

Idempotent functions are not time based and should not have a time variable.

This statement sounds like a "programming maxim" and I'm unfamiliar with the the reasons underlying it. Are there side-effects that I'm unaware of? It would be helpful to know the risks of making the solution "time-based".

you are tight coupling the router to the Transitioner

I don't think this causes tighter coupling. It doesn't create any kind of dependency between the two. Both can still operate just as independently as previously.

The router should reflect the route state not the transition state.

The only way to accurately time the transition is by knowing exactly when it start and ends. Again, I don't exactly see the risk or problem you're trying to convey here, or why this statement is necessarily true. It would help if you could describe specific problems you're trying to avoid since I don't inherently see the problem with this like you do.

And of course it's not react's way to do it like this. The state should flow in one direction alone, from parent to child

I'm not sure how this is being violated here. Please explain a little more.

you intend to ignore any dispatched action if you are in transition

I think this is a valid concern. See @rmevans9 comment above. We could log a warning in dev mode when trying to dispatch a navigate, back, or reset action during a transition, and encourage using onTransitionStart and onTransitionEnd events. Looking at that issue, batching would still work if the second action is a set_params. Is there a case you can think of where you'd want to do two navigate actions in a row? This would cause two transition animations in a row and I can't think of a reason we would need to allow that.

@davepack
Copy link
Member Author

Also, @dantman since you have been involved in discussions here, it would be good to get your take on this PR.

@rpopovici
Copy link

@davepack Ok. I will try to explain better. By "time based" solution I meant some piece of code which involves delays, timeouts, debouncing or anything involving a timer. In our case, we have to wait until the transition animation finishes in order to be able to dispatch again. Right now in react-navigation the dispatch action is able to do only: navigation, reset, setProps and go back. But maybe in the future this will not be the case anymore. We want to have a replace action, the possibility to have transitions with/without animations, batched transitions with individual or unified transition animations, better support for web, etc.

You solution is not allowing any kind of future development in this direction because it's ignoring any dispatch action, except for setProps, done during transitions. Maybe I have to reset the route in componentDidMount and the action will be ignored because it's happening during transition. Having a warning message will not help because I still don't know when the transition is ended. Another example: action dispatched after network request finish. What if this happens during transition. It's totally possible, especially if the animation runs in native thread. So, basically you are cutting out any way of doing programmatic navigation.

By "react way" I was referring to the fact that react encourages data flow in one direction only. From parent to child, from dispatcher to transitioner. In your implementation you are adding a new action SET_TRANSITON which btw is not very SOLID because it's not a routing action, it's only reflecting the transitioner state. This is violating the Separation of concerns principle. Anyway, that's not the problem. The problem is the fact that you expose this action publicly and make it accessible to the user and you are creating a feedback loop between dispatcher and transitioner. They are both react components if you did not noticed. Any dispatch will trigger a render in transitioner even for setParams.
What if somebody is dispatching with SET_TRANSITON_START and forgets to do a TRANSITON_STOP?!

Anyway, I am not saying that my solution is better. It was a quick hack I needed for my project, but so far I was unable to see anything superior to that. I think this should be simple. No complications.

  1. make the key to default to route's name + optional key in case you want to push same screen multiple times
  2. fall through when navigating with same key to insure idempotency
  3. remove random/unique generated keys because random it's not very idempotent by it's nature

@dantman
Copy link

dantman commented Sep 15, 2017

Also, @dantman since you have been involved in discussions here, it would be good to get your take on this PR.

  • I like the idea of having a callback to know when a transition finishes, however it doesn't appear this is what this PR is doing.
  • Adding more navigation state changes is a bad idea, the StackNavigator has a severe performance issue right now so changing dispatches from Navigation to Navigation > TransitionStart > TransitionEnd will make the navigation performance hits 3x worse (1 expensive re-render of the navigation stack will become 3).
  • Using state dispatches to notate the result of side-effects of state changes (a transition is a side-effect of the navigation state changing) also doesn't sit very well, the react-navigation dispatcher is based on redux and now that I actually am starting to understand redux (a separate issue from react-navigation forced me to refactor our app to use redux) this idea seems like it would be relatively un-redux.
  • I'm not sure about the exact flow of transitions of this PR, but if relevant, as I explained in another PR it would be a bad idea to make transactions sequential so that one always has to finish before another starts, there are various cases where you shouldn't wait.
    • It doesn't make sense to wait for a menu close transition to finish before starting a navigate transition. Waiting will make the app feel unresponsive (the user just tapped a button to navigate to another screen and they don't see that happening until we wait for the menu that button is in to disappear) and the navigation transition would have covered the menu transition anyways if we just did them in parallel.
    • If screen transitions are doubled up it doesn't always make sense to wait for one to finish, it's fairly reasonable one might want the screen transition that was just invoked to interrupt the current screen transition.
    • If I write navigator.navigate('B'); navigator.navigate('C'); in a sequence in the same tick, I most likely do not want to see the app transition to screen B, wait for that to finish, then transition to screen C. In this kind of situation I most likely want the app to visually transition directly from A -> C but want B in between state wise so that back goes C -> B then B -> A. It's impossible for this kind of transition to be made if dispatches and transitions are made so that they are queued and wait for each other since then the C navigation doesn't start till B is finished and it can't tell the transitioner that the B transition animation should be skipped entirely.

I do think an API to wait for transitions to finish is a good idea. Then it's possible to write a general solution for rejecting user interactions while something is happening and it can just be told to also wait for transitions to finish. However I believe the correct api for this is something based of how react-native's InteractionManager works. Very crudely, something like TransitionManager.runAfterTransitions(cb) would be a direct match to react-native's own APIs similar to this topic. However personally I'd probably prefer something more like TransitionManager.waitForTransitions() => promise (though I could just create a function or decorator for that). And of course if it makes sense Transitioner could be used instead of TransitionManager. And you could add other things like Transitioner.isTransitioning.

@davepack
Copy link
Member Author

@rpopovici @dantman thanks for your input, it's a Friday night, I want to be thoughtful about this so I'll pick this up again on Monday and respond. Have a good weekend!

@davepack
Copy link
Member Author

So stepping back a bit, the main problem we're trying to fix is when somebody double taps a button that dispatches a navigation action to push a new route onto a stack navigator, two transitions will occur and two of the same route will be pushed onto the stack.

I think this should be handled automatically in react-navigation, i.e. when devs upgrade react-nav this will just work without adding any code. I think arguments against handling this in react-nav usually speak to special cases that I think should be opt-in, i.e. a dev has to consciously add that feature into their code for their special case. Solutions that try to plan for numerous special cases usually end up having complicated logic.

As the code stands right now, the transition is tracked in navigation state. I think this is okay. Yes, it's technically a side-effect, but maybe this is a case where it's okay to make an exception. As far as I can tell there isn't another way to get the timing exactly right and prevent multiple pushes to the stack. Logic based on this state only has to check if navigation is in a transition. Other solutions require indirect logic that feels fragile.

The actions to set this state are dispatched from Transitioner. That is probably not so good. I am looking at other possibilities here.

In the router, getStateForAction blocks navigate, back, and reset when in a transition. This does not prohibit future development from updating to allow for special cases, the only requirement would be that future changes not break idempotent pushes for vanilla navigate, goBack, and reset.

With regards to performance and expensive re-rendering from state changes, this definitely needs to be looked at. Performance is probably something that should be looked at and fixed concurrently with or prior to fixing idempotent pushes. It's possible it will influence this solution, as you said @dantman.

Thoughts @rpopovici and @dantman?

@dantman
Copy link

dantman commented Sep 20, 2017

@davepack I still think the opposite. There are two major faults with react-navigation trying to implicitly handle the double-submit issue automatically.

First problem is determining what is and isn't a double submit.

  • We can't go by routeName because it's reasonable for a StackNavigator to have multiple instances of the same route with different params.
  • A custom key doesn't work with what you describe, because no current apps have it. That would be something developers would have to add in order to make work. And while I am not opposed to adding an optional key for those who want it, I am opposed to it being mandatory or the only way to solve double push.
  • A naive approach would be to then just try using routeNames+params. But that gets messy quickly.
    • You can't use !== equality to compare params. So you have to do a deep compare. Params change over time, sometimes being set immediately on mount so you have to deep copy/serialize the initial params to do a comparison to avoid that (deep copy/serialize already sounds iffy). While using serializable data is ideal, it's not a requirement, so it's reasonable a developer may use non-primitive data such as a function or Date object, or accidentally pass a NaNbecause of a math error (NaN !== NaN). And these non-serializable pieces of data cannot be automatically compared without complex custom comparisons. Or even if the developer sticks to serializable data but passes a timestamp as a param (say for a creation timestamp, initial state of a timer, etc...) or api fetched data that is different for each invocation (say a token with temporary permission for a screen to take an action) then the params will never deep compare because the params are always different even during a double-submit.
    • And the worst thing is that if you accidentally break this without knowing, you don't get an error or any obvious strange behaviour in your app. Instead you end up re-introducing the double-submit issue into your app, an issue you aren't even aware of because react-navigation is trying to hide it from you. This is the worst kind of issue, one that can silently creep into an app by a casual addition by any dev and does not have any visible effect to make the developer realize they just broke their app.

The second problem is that double submit is not a react-navigation exclusive issue. Getting two route items because of a double submit is only one of the possible side effects. If your handler makes an api call, the same double submit ends up making two api calls. If those api calls are a POST to create something, you've created two things. If that handler creates something with an api and then navigates you to a view/edit screen, you've created 2 things and react-navigation would just discard one of those navigations silently hiding the fact that you created two things instead of solving the issue. If your handler creates a local database item, double submit creates two items. If your handler deletes/moves/copies a file with an async delay, double submit makes the filesystem action and then emits an IO error when you try to do it again. If your handler is a "send" button, you end up sending two messages over whatever the relevant protocol is.

Idempotent navigation only solves the simplest possible double submit issue, an event handler that does absolutely nothing except navigate you to another screen. And leaves you SOL for everything more complex than that.

This also feels somewhat out of scope. The button is not a react-navigation button, the event handler is not a react-navigation handler. The application developer has placed a button on their screen, the application developer has written an event handler to handle presses on that button, and in that event handler the application developer has told react-navigation that they want react-navigation to navigate to a screen. This issue should be fixed at its source, the event handler that causes the double submit. (Lets put this another way, double submit is also a problem with forms on the web. But no-one says that <form>, ajax libraries, or other apis should be responsible for handling the bugs caused when a double submit invokes them twice)

IMHO, the correct solution is to make it possible for any handler to avoid the double submit issue. Fixing this basically means making it so that the application developer can make an event handler block invocation of the event handler when the previous invocation has not finished processing (whether that is due to ajax, navigation transitions, file/database IO, or something else). The best way to do this would be an easy to use library (or a collection of them, since some developers might want to wrap their functions, others might want to call a function from inside their handler, others might want to use decorators, and some may want to use callbacks to signify processing is finished while others want to use promises; though one lib could also cover those methods together). If a library like that doesn't already exist, then it would be best to create one, publish it, and make react-navigation recommend it to developers. This will also be a benefit to the overall react-navigation community because it will be a tool that is useful outside of just react-navigation.

The react-navigation specific portion of this issue is the transition. You can tell the library when an ajax request, file/database IO, or other async api has finished processing. But you can't tell when a transition is done. So the other thing we need is a simple API to tell you when any queued transitions have finished. I believe something roughly based on react-native's InteractionManager's prior art. Then this can be used in conjunction with the idempotent handlers library to quick and easily apply idempotent handling to any method with a navigate.

Here's a bit of pseudo code for a decorator obsessed developer's event handler that includes one possible way an idempotent event handler could be written.

import {Transitioner} from 'react-navigation';

// ...
	@autobind // bind the event handler
	@runAfterInteractions // Wait for button animations to complete before doing expensive api calls/transitions
	@idempotent // only allow one invocation of the event handler to happen at a time, reject all other invocations until the promise returned by this method is settled
	@waitForTransitions // automatically wrap the function so it waits for react-navigation transitions to finish
	@handleNetworkErrors // automatically catch generic network errors and display a message
	async function onSendButtonClicked() {
		// Send the message
		const {id} = this.props.api('/sendMessage', {message: this.state.message});

		// View the sent message
		this.props.navigation.navigate('MessageView', {id});

		// `@waitForTransitions` already waits for transitions, or we could instead use `await Transitioner.waitForTransitions();` here
	}

@rpopovici
Copy link

@davepack @dantman I would suggest a different approach to this.

  1. Having a 3rd party lib only for this is too much and increases complexity
  2. Using decorators or whatever fancy syntax might not work for some projects
  3. The Transitioner is exposing a transition event already. You can catch it with onTransitionStart andonTransitionEnd callbacks in StackNavigator
  4. Network or file operations don't work exactly the way @dantman explained. Actually they work very much the way react-navigation works now. Under the hood, file API marks and locks resources before they are being modified. From the user stand point of view, this can be a sync or async action depending on the library or operating system you work with.
  5. My proposal is simple. Currently we have TabNavigator which is idempotent. Only StackNavigator is not :) Maintaining the randomly generated key as @dantman suggest will break idempotency automatically. So, we can't do that. Using the routes name as a key is the way to go because TabRouter is doing the same and it would just be simple. Exposing an optional key is necessary (and per user request Add optional key to navigate action, allowing idempotent pushes #135 ) to handle situations like the one when you want to push the same route multiple times with or without different params..
  6. Of course, to offer some backwards compatibility for @dantman's projects, we can keep around the old StackRouter.
  7. Exposing an isTransitioning flag or some callbacks in the navigation API could be done as well, but this is not going to help too much since the dispatch logic is redux style. Basically, it's designed to work in a sync manner opposed to the way react works in general.

@rpopovici
Copy link

@dantman
Copy link

dantman commented Sep 20, 2017

@rpopovici

Having a 3rd party lib only for this is too much and increases complexity

Having a 3rd party lib does not increase complexity. Double submit is an issue that plagues more than just react-navigation, a 3rd party lib avoids NIH or putting out of scope things into react-navigation.

You do realize that react-navigation already depends on clamp, a 7 line package exporting a single function. Plus there is path-to-regexp, react-native-drawer-layout-polyfill, and react-native-tab-view.

Using decorators or whatever fancy syntax might not work for some projects

I never said that decorators were a requirement. I explicitly stated "or a collection of them, since some developers might want to wrap their functions, others might want to call a function from inside their handler, others might want to use decorators, and some may want to use callbacks to signify processing is finished while others want to use promises; though one lib could also cover those methods together" and gave an example "for a decorator obsessed developer's event handler". There is absolutely nothing about this solution that requires decorators or fancy syntax, this is a fundamental JavaScript issue which solve using any syntax or programming pattern you want.

Here are a bunch of samples for other possible ways you could write this pseudo code, you can bikeshed about what syntax or naming or code patterns you'd prefer all you want, this is possible in basically any one of them.

import {Transitioner} from 'react-navigation';

// ...
	onSendButtonClicked = idempotentHandler(() => {
		return this.props.api('/sendMessage', {message: this.state.message})
			.then((data) => {
				this.props.navigation.navigate('MessageView', {id: data.id});

				return Transitioner.waitForTransitions();
			});
	});

	onSendButtonClicked = idempotentHandler((done) => {
		this.props.api('/sendMessage', {message: this.state.message},
			(err, data) => {
				if ( err ) {
					done(err);
					return;
				}

				this.props.navigation.navigate('MessageView', {id: data.id});

				Transitioner.runAfterTransitions(done);
			});
	});

	function onSendButtonClicked() {
		try {
			const lock = takeInteractionLock(this); // this lock is instance-wide

			this.props.api('/sendMessage', {message: this.state.message},
				(err, data) => {
					if ( err ) {
						console.error(err);
						lock.release();
						return;
					}

					this.props.navigation.navigate('MessageView', {id: data.id});

					Transitioner.runAfterTransitions(() => {
						lock.release();
					});
				});
		} catch ( e ) {
			if ( e.code === 'INTERACTION_IS_LOCKED' ) {
				return;
			} else {
				throw e;
			}
		}
	}

	function onSendButtonClicked() {
		if ( takeInteractionLock(this, 'send') ) return; // this handler uses a lock category to make it per-handler

		this.props.api('/sendMessage', {message: this.state.message},
			(err, data) => {
				if ( err ) {
					releaseInteractionLock(this);
					return;
				}

				this.props.navigation.navigate('MessageView', {id: data.id});

				Transitioner.runAfterTransitions(() => {
					releaseInteractionLock(this, 'send');
				});
			});
	}

	function onSendButtonClicked() {
		if ( IdempotentHandler.isLocked(this) ) return;
		IdempotentHandler.startInteraction();

		this.props.api('/sendMessage', {message: this.state.message},
			(err, data) => {
				if ( err ) {
					IdempotentHandler.endInteraction();
					return;
				}

				this.props.navigation.navigate('MessageView', {id: data.id});

				Transitioner.runAfterTransitions(() => {
					IdempotentHandler.endInteraction();
				});
			});
	}

	// you can also skip a library if you want to write everything verbosely in your own project
	function onSendButtonClicked() {
		const that = this;
		if ( that.pending ) return;
		that.pending = true;

		that.props.api('/sendMessage', {message: that.state.message},
			function(err, data) {
				if ( err ) {
					that.pending = false;
					return;
				}

				that.props.navigation.navigate('MessageView', {id: data.id});

				Transitioner.runAfterTransitions(function() {
					that.pending = false;
				});
			});
	}

The Transitioner is exposing a transition event already. You can catch it with onTransitionStart andonTransitionEnd callbacks in StackNavigator

onTransitionEnd is currently a prop passed to Transitioner, not an event. You cannot listen to it from the event handler you trigger a navigation from.

Network or file operations don't work exactly the way @dantman explained. Actually they work very much the way react-navigation works now. Under the hood, file API marks and locks resources before they are being modified. From the user stand point of view, this can be a sync or async action depending on the library or operating system you work with.

Files locks are a bit of a red herring, in the second half of my comment when I lumped file IO in with database IO I was talking about overall event handlers. Basic file operations like delete and move happen extremely quickly (<10ms), double submit is a human scale issue that takes longer between event handler triggers (>10ms). By the time the second handler is triggered the delete/move operation has already completed and the file lock is released. The problem is overall event handlers that take time to complete and file operations that are not idempotent. ie: You cannot execute a move operation twice. The first (A->B) move operation moves A to B, the second (A->B) move operation throws an IO error because A does not exist.

My proposal is simple. Currently we have TabNavigator which is idempotent. Only StackNavigator is not :) Maintaining the randomly generated key as @dantman suggest will break idempotency automatically. So, we can't do that. Using the routes name as a key is the way to go because TabRouter is doing the same and it would just be simple.

This is a fundamental breaking change to or a misunderstanding of what a StackNavigator is.

TabRouter is a router that always has 1 route item per tab, you never have any more or less route items than the number of tabs you have. And navigate simply switches the active tab. The only reason TabRouter is idempotent is because all it does is change the active route.

StackRouter is not that. A StackRouter is a router that allows you to push any number of route items onto the stack. You always have at least 1, but the number of route items is not fixed to the number of routeNames you have, you can have less than that number or more. And navigate does not

The confusing thing here is that react-navigation uses a unified navigate action that just does whatever the default action is for a router type. This difference would be more understandable if react-navigation exposed them as push and jumpToTab navigation actions like ex-navigation.

Here's another example of a non-idempotent operation, goBack. If you double submit goBack you pop 2 items when you intended to only pop 1 item. And trying to make routeName+params/key idempotent won't make goBack idempotent. (this is just as relevant as saying TabNavigator is idempotent)

Exposing an isTransitioning flag or some callbacks in the navigation API could be done as well, but this is not going to help too much since the dispatch logic is redux style.

Wait callbacks / runAfter callbacks always wait a tick first to allow dispatches to execute and queue up transitions (just like react-native's own runAfterInteractions), they are unaffected by the redux style dispatch logic.

@dantman
Copy link

dantman commented Sep 20, 2017

@dantman btw, there are lots of 3rd party libs out there which are doing more or less what you want
https://github.com/satya164/react-navigation-addons
https://github.com/pmachowski/react-navigation-is-focused-hoc
https://github.com/spencercarli/react-navigation-enhancement-experiment

None of those libraries make event handlers idempotent or provide a way to wait for transitions to complete.

@rpopovici
Copy link

@dantman I know what the StackNavigator does. The solution proposed by #135 is ideal because gives you idempotency and an option to diverge from it if you have a special case where you have to push same route twice. So nothing changes, except for the better. Pushing same screen multiple times in a row is very unusual. And this is the core problem here. We are satisfying this peculiar use case but the more broader use cases are being marginalized. The main goal of this project from the beginning was to be EASY to use. We are diverging a lot from that philosophy by introducing all kinds of shady libraries to handle it and increase the core complexity at the same time.

@rpopovici
Copy link

And goBack() is idempotent except for the use case where you are trying to call it with param null. By default is binding the current route key

@dantman
Copy link

dantman commented Sep 20, 2017

The solution proposed by #135 is ideal because gives you idempotency and an option to diverge from it if you have a special case where you have to push same route twice. So nothing changes, except for the better. Pushing same screen multiple times in a row is very unusual. And this is the core problem here. We are satisfying this peculiar use case but the more broader use cases are being marginalized.

The core problem of double submit is not route pushes, it's the double submit. Fixing the double submit issue with an idempotent key doesn't fix that core problem, it just hacks around it while marginalizing the broader problem of double submit causing other bugs like double api calls (ie: instead of actually helping a developer fix double submit, we just make react-navigation workaround the effects specific to it so we can tell the developer "sorry, that's not our problem to solve" when double submit causes a bug other than double route pushes).

And while pushing a screen multiple times is unusual, I haven't seen a single person bother to offer an actual use case that is supposedly being marginalized other than double submit. I'd be happy to accept links if you have any, I'm pretty certain that any of those are also a case of something that actually has a bigger issue that should be solved instead of working around it using #135.

We are diverging a lot from that philosophy by introducing all kinds of shady libraries to handle it and increase the core complexity at the same time.

There is nothing shady or complex about using a new library. The react-native ecosystem is built on this foundation (react-navigation is just one of the 3rd party libraries you'll be using, even in Expo). We can even put any library we create under the react-community org and allow anyone in the community to help maintain it.

I also gave an example of doing this without using a library. The whole point of a library is to make fixing double submit easy by making it so the developer can just slap a decorator, function, or whatever other simple call they please to eliminate the code they need to write to handle their double submits themselves.

And goBack() is idempotent except for the use case where you are trying to call it with param null. By default is binding the current route key

Ok, you're correct about goBack. But then goBack is the same as tabnavigator.navigate, irrelevant to the idempotency issue because it's a fundamentally different type of command than stacknavigator.navigate.

  • tabnavigator.navigate (aka: jumpToTab) is a command saying "set the active tab to the pre-existing tab X"
  • stacknavigator.goBack (aka: pop) is a command saying "I am route X, please remove me from the stack and show whatever is before me"
  • stacknavigator.navigate (aka: push) is a command saying "You do not know who I am, please push a new route to the end of the stack and display it"

I still think double submit should be fixed by fixing double submit, but now that you bring up how goBack works I think there might be another way of making navigate/push idempotent that no-one is talking about.

The idempotent stacknavigator.navigate/push #135 appears to be describing is turning it into a command saying "You do not know who I am, please push a new route to the end of the stack and display it" (in which case react-navigation assumes a key based on the routeName unless the developer explicitly updates their code) or optionally "You do not know who I am, please push a new route identified by the key X to the end of the stack and display it" and then react-navigation deciding "I already see this key, so I'm not going to push it" (side note, this feels similar to the situation where you accidentally call setState on an unmounted component because of something async happening you didn't cancel, in which case React warns you to discourage it).

But what if we flipped that around and made stacknavigator.navigate work like goBack and be something other than a push. ie: What if we made stacknavigator.navigate a putAfter (or exclusivePutAfter) command saying "I am route X, please put a new route immediately after me and display it" and on double-submit react-navigation responding with/deciding "you already have a route immediately after you, I cannot put a new route". Or maybe a replaceAfter command saying "I am route X, please remove any routes after me from the stack, put a new route immediately after me, and display it" in which case the second submit wins out over the first (maybe not ideal, the first api call/io operation/etc... is normally what wins, so replaceAfter might be better as a different action).

  • This would work without requiring a key from the user, would work with the current unique/random, would work with simple use cases where you only have one route item per routeName, would also work with advanced use cases where you have multiple route items per routeName (normally this use case is one where you navigate to a route item with params and then from that route item or another route item navigate to another route item with the same routeName, so this still works because the parentKey is different), it also could pave the way to fixing the other issue that react-navigation lacks more advanced navigation actions such as jumpTo.
  • If we did this I would advocate adding an extra NavigatorActions.push to handle cases where you are in fact telling react-navigator to push something onto the stack instead of immediately after.
  • I also think I would advocate doing the same as setState and making it so that a warning is emitted when react-navigation rejects a dispatch due to double submit.
  • If we use Make sure dispatch can be called multiple times per-tick #1313 to solve the same-tick issue instead of an async queue, we can even keep the behaviour where react-navigation returns invalid/false when it rejects the route for this reason.

I still think we should offer developers a solution for making it easy for to fix double submit in all use cases, other than just react-navigation. But I wouldn't oppose adding this put + push behaviour in parallel with that.

@rpopovici
Copy link

@dantman finally we agree to somenthing. :) I like a similar solution to goBack for navigation

@dantman
Copy link

dantman commented Sep 20, 2017

@rpopovici Yay.

What do you think about also creating some sort of double-submit/idempotent event handler/interaction lock library (with multiple ways of using it so it fits into people's coding styles) under react-community and recommending it, at least in examples like we do with redux?

Also, if we're going with changing navigate to some sort of putAfter/putAfterExclusive, what do you think about combining that with adding in the various other actions missing from react-navigation (jump*, replace, etc...) and starting an RFC for it?

@rpopovici
Copy link

@dantman many Pub/Sub libraries are available already for JS e.g. https://github.com/facebook/emitter As for locking(as in mutex/semaphores), I don't think it really makes sense to have one since JS is single threaded and anyway, nowadays immutability+async logic it's more trendy e.g. immutable.js, promises and other functional stuff.

As for navigation actions, I think we should have a lot more than we have now:

  • pushAfter as default navigate with optional key=null to override idempotent behaviour
  • hoist to top. this should be the default navigate on android for DrawerNavigator. this is similar to pushAfter with the addition that will move an existing route to top if already pushed
  • popTo
  • replace
  • jumpTo
  • etc

@dantman
Copy link

dantman commented Sep 20, 2017

@dantman many Pub/Sub libraries are available already for JS e.g. https://github.com/facebook/emitter As for locking(as in mutex/semaphores), I don't think it really makes sense to have one since JS is single threaded and anyway, nowadays immutability+async logic it's more trendy e.g. immutable.js, promises and other functional stuff.

I'm not talking about pub/sub, mutexes, semaphores, or threading. locking probably isn't the best name for it, but there isn't any other good name. I'm talking about the simple library to ensure an event handler doesn't get invoked again while it is still doing asynchronous handling (network requests or navigation transitions). Using whatever coding pattern is preferred (promises, callbacks, etc...).

I'll put together an RFC and a comment based on what ex-navigation has but react-navigation is missing later. Done too much work today.

@rpopovici
Copy link

@dantman I don't know what ex-navigator does there but it must be either something like a deferred as in jQuery.Deferred, or a debounce as in lodash.debounce.

@rpopovici
Copy link

So @davepack I think you have your answer. The best way to approach this is to do it like goBack does it. You have to bind the key of the current route to the navigation action and fall through if it's not the last in the route stack. Thus there's a problem with this approach when you are trying to use dispatch directly. In that case you have to find another reference point to pass it as a key

@dantman
Copy link

dantman commented Sep 21, 2017

@dantman I don't know what ex-navigator does there but it must be either something like a deferred as in jQuery.Deferred, or a debounce as in lodash.debounce.

By RFC and ex-navigation I mean I'll make a comment with a list of missing navigation actions that ex-navigation's navigator has but react-navigation does not. Like the various jump and replace methods ex has.

Thus there's a problem with this approach when you are trying to use dispatch directly. In that case you have to find another reference point to pass it as a key

Manual dispatches from a route could just add navigation.state.key themselves. Though realistically I expect there are few people not using navigation.navigate inside routes, and special use cases outside scenes may want push instead anyways.

@sanjayholla94
Copy link

@davepack Any updates on this?

@ericvicenti
Copy link
Contributor

I think this is not the best way to accomplish idempotent pushes. Instead, lets have the navigate action accept a key which it can use to navigate to idempotently, like #2334

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

Successfully merging this pull request may close these issues.

Add optional key to navigate action, allowing idempotent pushes
7 participants