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: don't call handleSet if state hasn't changed #141

Closed

Conversation

pstrassmann
Copy link
Collaborator

Currently, handleSet gets called even if temporal state hasn't changed. One negative result of this is that throttles or debouncers to tracking history can get initialized even if no history-tracked state changes. This can result in unexpected behavior, such as the first instance of a history tracked state changing not getting registered in history, so long as it was preceded by a non-history tracked state changing.

This PR implements a change so that handleState is only called if pastState and currentState are not equal.

I'd appreciate some review and feedback, as I am new to this repo and want to make sure that I'm handling all edge cases correctly.

This PR has been tested locally against code like in this Sandbox:
https://codesandbox.io/p/sandbox/zundo-untrackedvalue-change-initiating-throttle-issue-wq9cm8?file=%2Fsrc%2FApp.tsx%3A58%2C11

Here is a gif of this fix working:
zundo

@charkour
Copy link
Owner

Hi @pstrassmann, thanks for the PR! Could you write tests that fail but pass with your fix?

I realize the bug is due to the curriedHandleSet being wrapped with options.handleSet in index.ts. The curriedHandleSet is wrapped by the throttle function. Your change moves the equality check to before the throttling, which fixes your issue. I'm wondering if this will cause regressions elsewhere or if this fixes bugs. Adding tests cases will help us determine that. Thank you!

@pstrassmann
Copy link
Collaborator Author

pstrassmann commented Dec 15, 2023

@charkour Yes, will do. I am also concerned with causing regressions or inadvertently introducing a breaking change.

I also have one other idea that I’ll write write up tomorrow that may be more in the zundo spirit of leaving implantation to the user.

The basic idea would be to allow the user access ‘previousState’ and ‘currentState’ inside the callback that consumers pass to the ‘handleState’ ‘ZundoOption’. With access to previous and current state, they could themselves then write the conditional that determines whether ‘handleState’ is run. But there may be snags I haven’t thought of, but I will keep chewing on it.

@@ -717,6 +727,184 @@ describe('Middleware options', () => {
);
expect(console.warn).toHaveBeenCalledTimes(2);
});

it('should not call throttle function if partialized state is unchanged according to equality fn', () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This test fails on main branch

vi.useRealTimers();
});

it('should not call throttle function if partialized state is unchanged according to diff fn', () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This test fails on main branch

vi.useRealTimers();
});

it('should always call throttle function on any partialized or non-partialized state change if no equality or diff fn is provided', () => {
Copy link
Collaborator Author

Choose a reason for hiding this comment

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

This test passes on main branch, but seemed helpful to explicitly define and to call out.

See this gif for what this looks like (notice useless history):

Sandbox: https://codesandbox.io/p/sandbox/zundo-handleset-partialize-with-no-equality-fn-and-no-diff-fn-zvs3sx?file=%2Fsrc%2FApp.tsx%3A23%2C37

Gif:
zundo-handleSet-partialize-no-equality-no-diff

src/index.ts Outdated
)
)
) {
curriedHandleSet(pastState);
Copy link
Owner

Choose a reason for hiding this comment

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

If we go forward with this, what do you think about passing currentState and deltaState to the curriedHandleSet?

Suggested change
curriedHandleSet(pastState);
curriedHandleSet(pastState, currentState, 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 this makes sense!

Copy link
Collaborator Author

@pstrassmann pstrassmann Dec 23, 2023

Choose a reason for hiding this comment

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

@charkour Actually I think we may run into some type issues doing this if we want to use a curriedHandleSet rather than something more like in PR: 142. I'm not positive and am trying to troubleshoot this now.

Expanding handleSet to expect additional params currentState and deltaState is currently causing some tangles, where we would need to know currentState and deltaState when we define curriedHandleState, which I'm not sure is possible (since it is defined outside of any function that sets zustand state so that currentState becomes a thing)

I'll keep thinking about it.

Copy link
Owner

Choose a reason for hiding this comment

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

Ah, that is tricky, maybe this way isn't doable without a major version change. Could you see what's possible to do without major version changes and then we could consider a future API for 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.

Yeah for sure, I'll keep noodling on this and see if I can find a solution that is more of something in between PR 141 and 142, biasing toward something that doesn't change the API

Copy link
Owner

Choose a reason for hiding this comment

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

Sounds good! Thanks for the help on this!

Comment on lines 62 to 64
if (get().isTracking) {
const currentState = options?.partialize?.(userGet()) || userGet();
const deltaState = options?.diff?.(pastState, currentState);
Copy link
Owner

Choose a reason for hiding this comment

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

Could you move these lines into the src/index.ts file?

src/index.ts Outdated
Comment on lines 82 to 84
options?.equality?.(pastState, currentState) ||
// If the user has provided a diff function but nothing has been changed, function returns null
deltaState === null
Copy link
Owner

Choose a reason for hiding this comment

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

Looking back at the old code, an improved heuristic would be to first check deltaState === null before checking equality because that could be a potentially expensive operation.

Copy link
Owner

Choose a reason for hiding this comment

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

Please make that change here.

src/index.ts Outdated
@@ -72,7 +72,21 @@ export const temporal = (<TState>(
// The order of the get() and set() calls is important here.
const pastState = options?.partialize?.(get()) || get();
set(...args);
curriedHandleSet(pastState);
const currentState = options?.partialize?.(get()) || get();
Copy link
Owner

Choose a reason for hiding this comment

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

For consistency, could you also make sure this change takes place in the setState function on line 58? Thanks!

Copy link
Collaborator Author

@pstrassmann pstrassmann Dec 23, 2023

Choose a reason for hiding this comment

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

Yeah can do. To be honest I haven't quite been able to wrap my head around what is going on on line 60. Do you happen to have a quick way of explaining what is going on there? 😅

Copy link
Owner

@charkour charkour Dec 23, 2023

Choose a reason for hiding this comment

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

Sure! The quick explanation is that zustand has two ways of setting state; one is during the creation method where set is used within the callback (used often) and the second is after the store is created, calling myStore.setState (which is less used):

const myStore = create((set) => ({ increment: set((state) => ({ bears: state.bears + 1 })});

myStore.setState({ bears: 99 });

The zundo user might prefer one setter over the other, but to make sure the middleware acts the same, we need to modify both setters. This was really brief, so I'm happy to elaborate more.

Copy link
Owner

@charkour charkour Dec 23, 2023

Choose a reason for hiding this comment

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

The set version is updated to track history within the config function call, and the setState version is directly mutated on the user's store object (the object returned by zustand/create or zustand/createStore).

Copy link
Collaborator Author

Choose a reason for hiding this comment

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

Beautiful, thank you!

@charkour
Copy link
Owner

Closing in favor of #149

@charkour charkour closed this Jan 14, 2024
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.

2 participants