-
Notifications
You must be signed in to change notification settings - Fork 121
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
Undo/Redo #105
Comments
There are two main approaches one can take when considering adding a redo/undo functionality to their app. It depends if your app is state-heavy or action-heavy. A state-heavy app is one that has a lot of information saved during the app's lifetime, but the reducers for the actions are thin and not computationally heavy. An action-heavy app is one that has a relatively thin state, but the reducers for the action do some heavy filtering, mapping, etc. Because of this, there are two types of libraries for implementing undo/redo in Redux. Ones that save a sequence of state versions in past, present and future arrays and ones that save a sequence of actions taken in history array and the current version in the present field. Considering Em has a relatively thin state and the actions for the shortcuts outlined in the issue are computation-heavy, I would suggest going with the first option and using a library that tracks state version changes rather than actions. The most popular library for that approach is redux-undo, which has 2.4k stars and offers some nice additional functionalities:
|
Thank you! I appreciate the explanation. With the approach of saving a sequence of state versions, does it save a diff of each state after a checkpoint, or does it save the entire state? |
It saves the whole state to avoid the merging overhead. This is time-efficient but, not that space-efficient if you have a rather large state. To counterpart this you can use some of the features outlined above: Only make specific reducers undoable, limit how many steps get saved, etc. Or just use the other approach and save only the actions. But then again, if your reducer logic is computation heavy, you need to be conscious of that as well. |
Yeah, I think with the size of
These aren't viable options for us, since we need full undo/redo for all the commands listed in the issue.
This seems like the right path to me. I'm okay having a slight performance hit on the actual undo/redo execution if it is more space-efficient. Does this approach rely on inverse actions? It seems like that would be the only way to go backwards through history. Otherwise you would have to go forwards from the beginning of the history each time to get to the desired state. |
Yes, it replays the entire history from the beginning. The thing about the reverse actions is that it becomes non-trivial for complex ones. Simple INCREMENT-like actions that have only type field can have a counterpart DECREMENT action, but what about the actions that take in some kind of value? That's why it is better advised to save states in history. Replaying everything can become too heavy at some point. |
Good point. I can see that getting complex, especially with new actions being added.
I have point out again that the state in em contains tens of thousands of free text entries. Even with a small number of undo steps, such as 20, that seems untenable from a storage performance perspective. Given the IndexedDB limit, it effectively reduces how much can be stored locally by a factor of the number of history steps that are supported. (FWIW, server-side caching will have to be implemented at some point which will reduce the state size, but that requires a large change of infrastructure and has wider security implications.) It seems like the best approach would be to save the diff of each state change. That would be both space efficient and more computationally efficient than replaying the full action. What are your thoughts about this approach? Is there a functional downside? Why don't existing undo/redo frameworks do this? I would potentially be open to the conventional action replay approach to start with, and then consider optimizing in the future with a more performant approach like the above in the future. Thanks for addressing these specific concerns given the heavy state of em. |
Storing diffs has 2 main problems with efficiency:
Yes, let's go with the more conventional action replay approach first. |
@raineorshine I believe that there are a couple of options for implementing this. The approach of Based on my understanding, these are the two options that might be work considering -
For any of these to work correctly, the reducers need to be pure functions without any side effects. |
Thanks for reviewing the approaches!
I’m not convinced the two complications above are valid. I don’t know yet whether this is relevant for the solution you are proposing, but I don’t wish to rule out diffs yet, for the following reasons:
I don’t believe this is relevant given that storing the entire state is not an option.
Immer has very fast deep object diffing. Our state does not have a lot of depth. Here's some more discussion on that. The points about references and gc in this comment are valid for in-memory references but I don’t believe it applies to persisted history. We need a solution that supports serialization and persistence of the history.
I like the grouping functionality.
Unfortunately this won’t allow persistent history and won’t work with the new iterative loading mechanism where not all state is in memory at the same time.
I am still quite interested in this approach, as it would be nice to be able to “revert” the state one action or group of actions at a time, from present to past, without requiring any snapshots or replays. However my thinking is that if we are going to use inverse actions, why not simply generate an inverse patch for each regular action? Immer can do this easily. Then we have a general solution which works for all actions. We would just need to devise a mechanism to handle the grouping.
Agreed. On an unrelated note, I am concerned about how undo/redo will work with the new iterative loading. What’s happens when part of the state that is being undone is no longer in memory? We’ll need to think carefully about that. We don’t want to choose an approach that is incompatible with iterative loading, though I regret the additional complexity this involves. Looking forward to hearing what you think! |
@raineorshine The complexity with the diffs is manually maintaining the additional payload/metadata, which describes how to apply them.
This should work if go with the hybrid approach of storing state references after a certain number of actions.
I looked at
That doesn't seem like a problem if iterative loading is gonna be based on pure functions/reducers. The patches/action-replays would store the payloads as well, which should be sufficient to rebuild the state at any given point in time. |
Maybe it won't be so hard with state = applyPatches(state, changes)
By state references, do you mean in-memory references? What happens when the page is refreshed? Maybe you have a serialization scheme in mind that I am not aware of.
Okay, good to know. How would the reducers use
Yes, this seems like the right approach.
Yeah, it'd be good for me to be more specific here. Let me give this some more thought. Thanks! |
By state references, I mean 'deep clones' of state after every fixed of actions.
Great! Should we start with this or explore the options of doing it in the @shresthabijay I believe you've had some experience with |
@raineorshine I believe we can use a |
Okay, thanks for clarifying. Let's refer to them as clones. References usually refer to shallow copies or pointers in Javascript, not clones. Deep cloning state is going to increase the memory consumption significantly. If you think this is a viable option, you will need to provide a convincing argument with specific reasons as to why it is a better approach than inverse patches, which do not require cloning the state.
Is this a flag you are making, or part of a library? Keep in mind that we need to persist undo/redo using our existing data infrastructure, i.e. to dexie and remote, via the
So will the patches somehow be generated from these calls to
Yes, let's investigate this further. Changing all of the reducers touches a lot of code and makes them more tightly coupled to the undo/redo logic which I would prefer to avoid.
We still have some investigation to do. Once we have what looks like a viable solution, you should write up a short proposal that describes how it will meet each of the requirements. |
No, I understand the desire to get into the code, but a task like this requires a more rigorous proposal that demonstrates the viability of the approach. Otherwise we end up tinkering with code and risking a dead end with an approach that does not meet the requirements from the beginning. |
Sure, that makes sense. In order to keep our changes minimal and avoid refactoring our existing users, I propose writing a
While making use of our existing reducers, we can store the |
Yes! That looks workable. Now we can make a plan for grouping, including how we specify which actions are grouped together, and how patches are applied as a group. We can be somewhat less detailed with the persistence mechanism. Since iterative loading is not yet in |
We'll need a list of undoable actions and the list of such groups of actions that need to be treated as a single, undoable action.
For each action, we can check the last item of the array and decide if the current action should be added to the previous group or represent a new group. |
Thanks! Question: How should we handle non-undoable actions mixed in with undoable actions? This deserves some thought. It would be less than ideal for a user to be blocked from additional undos due to a non-undoable action at the top of the history. Please describe how/when the undo history will be persisted via
The grouping is algorithmic, so a simple list won't suffice. How do you suggest this be represented? Please provide justification and any alternatives considered.
An array of arrays, or an array of objects? 2-dimensional array refers to an array of arrays, but your example looks like an array of objects. Let me know if I'm missing something.
That makes sense at a high level. Would you provide a more detailed, step-by-step account of the grouping mechanism? |
I updated the OP with undoable and non-undoable actions. A couple more requirements things came to mind: AtomicityGroups of patches should be applied atomically, i.e. it should be guaranteed that all are applied or none at all. This rules out dispatching individual undo actions for each patch. My guess is that you will need to use something like Syncing UpdatesWe are going to need to extract the |
updateThoughts
This is also an issue not just for However we handle |
Upon further analysis, I found out that |
@raineorshine As discussed, we'd start with local |
@raineorshine Our initial decision was to store the diffs for every action and combine them into a single diff as soon as an action related to the I tried out this approach with the This alone, would still not be efficient if the user is just navigating through the thoughts because we'd be computing the diffs (and storing them) on every cursor action. To handle that, we can add a throttling mechanism. Please share your thoughts. |
We did discuss this, although I recall coming to a different conclusion. I had presented two options towards the end of our call:
We had both agreed that the second option was preferable. That said, the performance issue you described probably applies to both approaches.
Darn. So can you say for sure if it's more of a computational bottleneck, or memory bottleneck? If memory, that might deserve more exploration. Unless there is a memory leak, I don't see how typing would increase memory over time, assuming we utilize approach (2) as described above, which modifies the last patch repeatedly instead of appending new patches.
I have mentioned this before, but we cannot store the entire
I should note that the cursor navigation is not the defining action of an undo boundary. Take the following sequence of actions for example:
These represent three distinct undoable states, although no cursor actions are involved.
I'm first curious about the answer and possible research into my above question regarding the performance bottleneck cause. If JSON diffs are too performance intensive, then we will have to explore other options. It would be great for you to do further investigation and brainstorming to assess potential options. Thanks! |
I believe this is where I got wrong because I tried out the first approach, which included storing a patch for every action and combining them when a new
I tried approach (2) and the memory is not a bottleneck with that. I tried it out with ~100 thoughts and there were no noticeable performance issues so far.
Agreed! In that case, we need to identify all such undo boundaries. However, we can simply start with the Or else, I can try to find out all the use-cases that I can, have them noted in the issue, and then start working.
As I mentioned above, the performance looks good with the second approach that we agreed upon (the one that I forgot 😅 ). |
Great!
I actually think it's fairly deterministic. Some pseudocode: On each action... patch = createPatch(action)
if ((isNavigation(action) && isNavigation(prevAction)) ||
(isExistingThoughtChange(action) && isExistingThoughtChange(prevAction))) {
mergeWithPrevPatch(patch)
}
else {
appendPatch(patch)
} On undo/redo... if (isNavigation(action)) {
re/undo() // undo navigation patch
}
re/undo() // undo action patch Is this correct?
So glad! Forgetting is a much easier problem to solve 😁
Noted. We'll be able to do meaningful performance testing again after #503. |
I think we should go for an inverse approach where we merge every action except the ones that define the
Alternatively, we can ignore such actions because calculating diffs for actions like the |
@raineorshine I've been able to get the basic If that's correct, however, we'd have to address that concern first as a separate issue and make sure that the redux store represents the state of the app at any given point in time. |
Let's refer to the browser selection as "browser selection" or "caret" so as not to confuse it with
Actually, we set the caret position here: https://github.com/cybersemics/em/blob/dev/src/components/Editable.tsx#L214
Agreed |
That makes sense
Okay, let me see if we can use this one on every |
In order to position the caret correctly on an When an In order to position the caret correctly in case of undo/redo operations, we'd need to ensure that it is controlled directly by the state. |
That makes sense.
Since
Okay |
You're right. It's being calculated from
The
We're updating it based on this condition in I'm not quite sure what Looking forward to your thoughts on this! |
That's correct.
After creating a new thought and editing it, yes. Technically the user could delete an empty thought and then hit undo, which should result in the cursor restored to the empty thought.
I see!
Due to a limitation in the When Bijay fixes
We cannot update
If there is a way to set aside the caret issue for now until we know if Bijay has a solution to |
@shresthabijay As @raineorshine pointed out, I suppose that you're working on the |
My solution prevents caret to reset when |
@anmolarora1 I have sent the PR #823. This will allow |
@shresthabijay Can you be more specific? Are you referring to the |
|
Add Undo and Redo functionality.
Requirements:
Groups
Each
undo
should be associated with one or more actions.Consider the following sequence of actions:
These should be undoable in a single
undo
.Another example, based on the following state:
Activating
undo
should undo 5-8, i.e. navigation and contiguous edits.Actions
Undoable
The following action types should be undoable:
Non-undoable
Patches should not be generated for the following actions and they should be ignored completely by the undo/redo history:
The text was updated successfully, but these errors were encountered: