Skip to content

Comments

handleSet refactor option 3#149

Merged
charkour merged 7 commits intocharkour:mainfrom
pstrassmann:pstrassmann-handleSet-option3-exploration
Jan 14, 2024
Merged

handleSet refactor option 3#149
charkour merged 7 commits intocharkour:mainfrom
pstrassmann:pstrassmann-handleSet-option3-exploration

Conversation

@pstrassmann
Copy link
Collaborator

This PR is to explore the solution space around ideas around this issue, specifically based on this comment

TL;DR This PR and PR 141 look very similar now at first glance. To see the primary difference, note how curriedHandleSet takes different numbers of parameters in the two PRs.

Background:
In PR 141, there are zero changes to the ZundoOptions API. However, in bringing the state change check in prior to calling curriedHandleSet, we have the minorish drawback of having to calculate currentState and deltaState BOTH 1) to calculate the check for calling curriedHandleSet and 2) within curriedHandleSet.

An idea was then -- instead of calculating those values twice, maybe we just calculate those things once and just pass them to curriedHandleSet. So that's what we do in this PR.

While we resolve the redundant calculation issue, the drawback though is that we cannot both let curriedHandleSet accept more parameters and not simultaneously have absolutely zero changes to the API. Like PR 142, options.handleSet now returns a function that, in addition to pastState as before, now technically also receives the values of currentState and deltaState. So like in PR 142, the minor change to the API is that users now also have access to these values and can use them in logic (although in this PR they no longer need to to resolve the throttling/untrackedValue issues themselves with additional logic, as that is now, like in PR 141, always resolved by the conditional check if equality or diff functions exist.

All that being said, I believe this change (this PR) is non breaking and all current compositions of handleSet will continue to work as expected (the same is true of the other PRs as well). All tests pass, and this was tested locally with previous compositions of handleSet.

src/index.ts Outdated
Comment on lines 53 to 62
const userHandleSet = options?.handleSet?.(
(store.temporal.getState() as _TemporalState<TState>)
._handleSet as StoreApi<TState>['setState'],
);

const internalHandleSet = (
store.temporal.getState() as _TemporalState<TState>
)._handleSet;

const curriedHandleSet = userHandleSet || internalHandleSet;
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For my understanding, these lines here are a more verbose way to write these four lines, correct?

   const curriedHandleSet =
      options?.handleSet?.(
        (store.temporal.getState() as _TemporalState<TState>)._handleSet,
      ) || (store.temporal.getState() as _TemporalState<TState>)._handleSet;

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If so, could we revert these changes to cut down on the number of lines changed? Thanks!

src/temporal.ts Outdated
Comment on lines 61 to 62
_handleSet: (pastState, currentState, deltaState) => {
if (get().isTracking) {
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this if statement also be moved into temporalHandleSet()?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Taking a look at this now, I'm wondering if _handleSet should simply be removed from the temporal store because it's never externally referenced. What do you think?

Copy link
Collaborator Author

@pstrassmann pstrassmann Jan 5, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re removing _handleSet -- my apologies, I am not totally confident I understand the potential refactor.

Although _handleSet shouldn't be accessed by the user (it is notably omitted from the TemporalState type, along with _onSave), _handleSet does still need access to get and set params passed by createStore to the stateCreator function returned by temporalStateCreator.

And then _handleSet is accessed in the curriedHandleSet expression via (store.temporal.getState() as _TemporalState<TState>)._handleSet;

I'm just trying to puzzle through how _handleSet might otherwise have access to get and set of the temporal store

EDIT: thinking more about this, I think I am understanding. We could remove it from the temporal store and just use store.temporal.getState() and store.temporal.setState

vaguely like:

// index.ts, below where we add `store.temporal = createStore...`

const handleSet = (pastState, currentState, deltaState) => {
      if (store.temporal.getState().isTracking) {
        // This naively assumes that only one new state can be added at a time
        if (options?.limit && store.temporal.getState().pastStates.length >= options?.limit) {
          store.temporal.getState().pastStates.shift();
        }

        store.temporal.getState()._onSave?.(pastState, currentState);
        store.temporal.setState({
          ...store.temporal.getState(),
          pastStates: get().pastStates.concat(deltaState || pastState),
          futureStates: [],
        });
      }
    }

Is this what you had in mind?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re moving that if statement, yes that makes sense!

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes! Your code snippet is what I had in mind; however, this is simply a hunch that it will work but I haven't tested it.

We could leave it as a follow-up PR to avoid many changes in a single PR. What do you think about you or I making this change after this current PR is merged?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that sounds good! Happy to make a follow up PR after this one to test it out.

src/types.ts Outdated
Comment on lines 39 to 43
) => (
pastState: PartialTState,
currentState: PartialTState,
deltaState?: Partial<PartialTState> | null,
) => void;
Copy link
Owner

@charkour charkour Dec 28, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My only concern here is that this is technically a breaking type change. Have you considered (this mess of a) type signature to avoid breaking type changes?

Suggested change
) => (
pastState: PartialTState,
currentState: PartialTState,
deltaState?: Partial<PartialTState> | null,
) => void;
) => (
partial: Parameters<StoreApi<TState>['setState']>[0], // or maybe PartialTState
replace: Parameters<StoreApi<TState>['setState']>[1], // or maybe boolean | undefined
currentState: PartialTState,
deltaState?: Partial<PartialTState> | null,
) => void;

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I had not previously considered this, but if it is okay with you then it is okay with me! Very cleverly avoids the breaking change!

I doubt that users will ever want to access the value of replace in the function returned by their handleSet function, but they already can as is. I will update to reflect this on my next push. I'll plan on passing undefined as the value for "replace" given to curriedHandleSet, which is what the value is now.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's plan on this to avoid a breaking change. Can you please add a comment in the future replace will likely be deprecated and removed? Thanks!

Copy link
Owner

@charkour charkour left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the very detailed explanation and writeup! Just a couple of small comments for you to address before we merge this in. Out of all three options, I'm leaning toward this one. Based on your use case, does this one make sense to you, @pstrassmann? Thank you

@pstrassmann
Copy link
Collaborator Author

@charkour Hey! Sorry I will get to all of this this week! Thank you!

@charkour
Copy link
Owner

charkour commented Jan 4, 2024

No rush. Happy new year!

Comment on lines 379 to +389
### Cool-off period

`handleSet?: (handleSet: StoreApi<TState>['setState']) => StoreApi<TState>['setState']`
```typescript
handleSet?: (handleSet: StoreApi<TState>['setState']) => (
pastState: Parameters<StoreApi<TState>['setState']>[0],
// `replace` will likely be deprecated and removed in the future
replace: Parameters<StoreApi<TState>['setState']>[1],
currentState: PartialTState,
deltaState?: Partial<PartialTState> | null,
) => void
```
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I have some concerns that this type definition may spook new users reading the README, but it also seems like a good idea to be accurate here?

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm wondering if there are easier ways to write this out. If not, it is what it is and we can simplify this in v3.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure. The use of the Parameters utility type is actually easier on the eyes to me than directly using/copying/extending the types from SetStateInternal from zustand:

type SetStateInternal<T> = {
    _(partial: T | Partial<T> | {
        _(state: T): T | Partial<T>;
    }['_'], replace?: boolean | undefined): void;
}['_'];

I think the addition in this PR just looks ugly (in part?) because, whereas previously replace was hidden within StoreApi<TState>['setState'], we make it visible here in order to add the two additional parameters currentState and deltaState

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree. Let's merge this in as is, but I'll make a follow-up PR with some options.

@pstrassmann
Copy link
Collaborator Author

@charkour Yes this PR I think is the best of all the options. Happy to close out the other ones and merge this one in. Very happy we were able to land on a solution without any breaking changes to the API! Thanks again for all your help on this, and I'm looking forward to helping grow and improve zundo! It is really amazing how quickly zundo enabled our application at Classy to have performant undo/redo functionality across our features. Zustand is the best, and made even better with great extensions like the one you've built!

Happy to address any additional feedback on this PR as well. Thank you!

@charkour
Copy link
Owner

Thanks for the awesome work! I'm glad that zundo is very helpful when adding new features to your platform at work.

I plan to review this soon and would like to release a new version this weekend. Sorry for the delay on this!

@charkour charkour self-requested a review January 11, 2024 22:04
@pstrassmann
Copy link
Collaborator Author

Thank you!! 🙏

options?.handleSet?.(
(store.temporal.getState() as _TemporalState<TState>)._handleSet,
(store.temporal.getState() as _TemporalState<TState>)
._handleSet as StoreApi<TState>['setState'],
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is this typecase needed? Is that because this function technically takes four parameters including currentState and deltaState?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah that is right. I believe it is required due to the fact that curriedHandleSet is used to call either the internal handle set or the user-provided handleSet.

Perhaps this is a circular explanation, but options.handleSet takes an argument of type StoreApi<TState>['setState'].
However, this is not the type of _handleSet, which takes four arguments. So asserting it to the be the type expected by options.handleSet resolves the type error.

I'll be the first to admit that this part of the code is not the easiest to read/follow. I don't immediately have an alternative suggestion, but I would certainly be open to something more approachable in upcoming versions.

Copy link
Owner

@charkour charkour left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the amazing work on this! Before we release a new version, I'll plan on you removing _handleSet from the internal zustand store and I'll work on updating some types.

Thank you!

@charkour charkour merged commit ca10a36 into charkour:main Jan 14, 2024
@pstrassmann
Copy link
Collaborator Author

@charkour Ok sounds good! I'll find some time in the next couple days to get the followup PR in. Thank you!

@charkour
Copy link
Owner

Looking more at the types, I think this is as good as we are going to get it without breaking API changes. I'll work on a v3 branch with simplification.

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.

3 participants