Skip to content

Commit

Permalink
restructure __internals to shave bytes; add react tests (#82)
Browse files Browse the repository at this point in the history
  • Loading branch information
charkour authored Apr 22, 2023
1 parent 86e2b62 commit 2145dfe
Show file tree
Hide file tree
Showing 12 changed files with 966 additions and 134 deletions.
97 changes: 97 additions & 0 deletions apps/web/pages/reactive.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { TemporalState, temporal } from 'zundo';
import { StoreApi, useStore, create } from 'zustand';

interface MyState {
bears: number;
increment: () => void;
decrement: () => void;
}

const useMyStore = create(
temporal<MyState>((set) => ({
bears: 0,
increment: () => set((state) => ({ bears: state.bears + 1 })),
decrement: () => set((state) => ({ bears: state.bears - 1 })),
})),
);

type ExtractState<S> = S extends {
getState: () => infer T;
} ? T : never;
type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'subscribe'>;
type WithReact<S extends ReadonlyStoreApi<unknown>> = S & {
getServerState?: () => ExtractState<S>;
};

const useTemporalStore = <S extends WithReact<StoreApi<TemporalState<MyState>>>, U>(
selector: (state: ExtractState<S>) => U,
equality?: (a: U, b: U) => boolean,
): U => {
const state = useStore(useMyStore.temporal as any, selector, equality);
return state
}

const HistoryBar = () => {
const futureStates = useTemporalStore((state) => state.futureStates);
const pastStates = useTemporalStore((state) => state.pastStates);
return (
<div>
past states: {JSON.stringify(pastStates)}
<br />
future states: {JSON.stringify(futureStates)}
<br />
</div>
);
};

const UndoBar = () => {
const { undo, redo } = useTemporalStore((state) => ({
undo: state.undo,
redo: state.redo,
}));
return (
<div>
<button onClick={() => undo()}>undo</button>
<button onClick={() => redo()}>redo</button>
</div>
);
};

const StateBar = () => {
const store = useMyStore();
const { bears, increment, decrement } = store;
return (
<div>
current state: {JSON.stringify(store)}
<br />
<br />
bears: {bears}
<br />
<button onClick={increment}>increment</button>
<button onClick={decrement}>decrement</button>
</div>
);
};

const App = () => {
return (
<div>
<h1>
{' '}
<span role="img" aria-label="bear">
🐻
</span>{' '}
<span role="img" aria-label="recycle">
♻️
</span>{' '}
Zundo!
</h1>
<StateBar />
<br />
<UndoBar />
<HistoryBar />
</div>
);
};

export default App;
5 changes: 4 additions & 1 deletion apps/web/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
{
"extends": "tsconfig/nextjs.json",
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
"exclude": ["node_modules"]
"exclude": ["node_modules"],
"compilerOptions": {
"jsx": "react-jsx"
}
}
2 changes: 1 addition & 1 deletion packages/zundo/__tests__/createVanillaTemporal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('createVanillaTemporal', () => {
};
});

const temporalStore = createVanillaTemporal(store.setState, store.getState);
const temporalStore = createVanillaTemporal(store.setState, store.getState, (state) => state);
const { undo, redo, clear, pastStates, futureStates } =
temporalStore.getState();
it('should have the objects defined', () => {
Expand Down
49 changes: 22 additions & 27 deletions packages/zundo/__tests__/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -310,7 +310,7 @@ describe('Middleware options', () => {

it('should call a new onSave function after being set', () => {
global.console.info = vi.fn();
global.console.log = vi.fn();
global.console.warn = vi.fn();
global.console.error = vi.fn();
const storeWithOnSave = createVanillaStore({
onSave: (pastStates) => {
Expand All @@ -325,11 +325,11 @@ describe('Middleware options', () => {
});
expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(2);
expect(console.info).toHaveBeenCalledTimes(2);
expect(console.log).toHaveBeenCalledTimes(0);
expect(console.warn).toHaveBeenCalledTimes(0);
expect(console.error).toHaveBeenCalledTimes(0);
act(() => {
setOnSave((pastStates, currentState) => {
console.log(pastStates, currentState);
console.warn(pastStates, currentState);
});
});
act(() => {
Expand All @@ -338,7 +338,7 @@ describe('Middleware options', () => {
});
expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(4);
expect(console.info).toHaveBeenCalledTimes(2);
expect(console.log).toHaveBeenCalledTimes(2);
expect(console.warn).toHaveBeenCalledTimes(2);
expect(console.error).toHaveBeenCalledTimes(0);
act(() => {
setOnSave((pastStates, currentState) => {
Expand All @@ -351,7 +351,7 @@ describe('Middleware options', () => {
});
expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(6);
expect(console.info).toHaveBeenCalledTimes(2);
expect(console.log).toHaveBeenCalledTimes(2);
expect(console.warn).toHaveBeenCalledTimes(2);
expect(console.error).toHaveBeenCalledTimes(2);
});
});
Expand Down Expand Up @@ -425,17 +425,16 @@ describe('Middleware options', () => {
expect(storeWithHandleSet.temporal.getState().futureStates.length).toBe(
2,
);
expect(console.log).toHaveBeenCalledTimes(2);
expect(console.warn).toHaveBeenCalledTimes(2);
});
});

describe('secret internals', () => {
it('should have a secret internal state', () => {
const { __internal } =
const { __handleUserSet, __onSave } =
store.temporal.getState() as TemporalStateWithInternals<MyState>;
expect(__internal).toBeDefined();
expect(__internal.handleUserSet).toBeInstanceOf(Function);
expect(__internal.onSave).toBe(undefined);
expect(__handleUserSet).toBeInstanceOf(Function);
expect(__onSave).toBe(undefined);
});
describe('onSave', () => {
it('should call onSave cb without adding a new state when onSave is set by user', () => {
Expand All @@ -446,13 +445,12 @@ describe('Middleware options', () => {
console.error(pastStates, currentState);
});
});
const { __internal } =
const { __onSave } =
store.temporal.getState() as TemporalStateWithInternals<MyState>;
const { onSave } = __internal;
act(() => {
onSave(store.getState(), store.getState());
__onSave(store.getState(), store.getState());
});
expect(__internal.onSave).toBeInstanceOf(Function);
expect(__onSave).toBeInstanceOf(Function);
expect(store.temporal.getState().pastStates.length).toBe(0);
expect(console.error).toHaveBeenCalledTimes(1);
});
Expand All @@ -463,11 +461,10 @@ describe('Middleware options', () => {
console.info(pastStates);
},
});
const { __internal } =
const { __onSave } =
storeWithOnSave.temporal.getState() as TemporalStateWithInternals<MyState>;
const { onSave } = __internal;
act(() => {
onSave(storeWithOnSave.getState(), storeWithOnSave.getState());
__onSave(storeWithOnSave.getState(), storeWithOnSave.getState());
});
expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(0);
expect(console.error).toHaveBeenCalledTimes(1);
Expand All @@ -483,7 +480,7 @@ describe('Middleware options', () => {
act(() => {
(
storeWithOnSave.temporal.getState() as TemporalStateWithInternals<MyState>
).__internal.onSave(
).__onSave(
storeWithOnSave.getState(),
storeWithOnSave.getState(),
);
Expand All @@ -501,7 +498,7 @@ describe('Middleware options', () => {
act(() => {
(
storeWithOnSave.temporal.getState() as TemporalStateWithInternals<MyState>
).__internal.onSave(store.getState(), store.getState());
).__onSave(store.getState(), store.getState());
});
expect(store.temporal.getState().pastStates.length).toBe(0);
expect(console.dir).toHaveBeenCalledTimes(1);
Expand All @@ -511,31 +508,29 @@ describe('Middleware options', () => {

describe('handleUserSet', () => {
it('should update the temporal store with the pastState when called', () => {
const { __internal } =
const { __handleUserSet } =
store.temporal.getState() as TemporalStateWithInternals<MyState>;
const { handleUserSet } = __internal;
act(() => {
handleUserSet(store.getState());
__handleUserSet(store.getState());
});
expect(store.temporal.getState().pastStates.length).toBe(1);
});

it('should only update if the the status is tracking', () => {
const { __internal } =
const { __handleUserSet } =
store.temporal.getState() as TemporalStateWithInternals<MyState>;
const { handleUserSet } = __internal;
act(() => {
handleUserSet(store.getState());
__handleUserSet(store.getState());
});
expect(store.temporal.getState().pastStates.length).toBe(1);
act(() => {
store.temporal.getState().pause();
handleUserSet(store.getState());
__handleUserSet(store.getState());
});
expect(store.temporal.getState().pastStates.length).toBe(1);
act(() => {
store.temporal.getState().resume();
handleUserSet(store.getState());
__handleUserSet(store.getState());
});
});

Expand Down
40 changes: 40 additions & 0 deletions packages/zundo/__tests__/react.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, it, expect } from 'vitest';
import React from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import Reactive from '../../../apps/web/pages/reactive';

describe('React Re-renders when state changes', () => {
it('it', () => {
const { queryByLabelText, getByLabelText, queryByText, getByText } = render(
<Reactive />,
);

expect(queryByText(/bears: 0/i)).toBeTruthy();
expect(queryByText(/increment/i)).toBeTruthy();
expect(queryByText(/past states: \[\]/i)).toBeTruthy();
expect(queryByText(/future states: \[\]/i)).toBeTruthy();

const incrementButton = getByText(/increment/i);
fireEvent.click(incrementButton);
fireEvent.click(incrementButton);

expect(queryByText(/bears: 2/i)).toBeTruthy();
expect(queryByText(/past states: \[{"bears":0},{"bears":1}\]/i)).toBeTruthy();
expect(queryByText(/future states: \[\]/i)).toBeTruthy();

expect(queryByText(/undo/i, {
selector: 'button',
})).toBeTruthy();

const undoButton = getByText(/undo/i, {
selector: 'button',
});

fireEvent.click(undoButton);
fireEvent.click(undoButton);

expect(queryByText(/bears: 0/i)).toBeTruthy();
expect(queryByText(/past states: \[\]/i)).toBeTruthy();
expect(queryByText(/future states: \[{"bears":2},{"bears":1}\]/i)).toBeTruthy();
});
});
11 changes: 11 additions & 0 deletions packages/zundo/__tests__/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { expect, afterEach } from 'vitest';
import { cleanup } from '@testing-library/react';
import matchers from '@testing-library/jest-dom/matchers';

// extends Vitest's expect method with methods from react-testing-library
expect.extend(matchers);

// runs a cleanup after each test case (e.g. clearing jsdom)
afterEach(() => {
cleanup();
});
5 changes: 5 additions & 0 deletions packages/zundo/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,15 +40,20 @@
},
"devDependencies": {
"@size-limit/preset-small-lib": "8.2.4",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "14.0.0",
"@types/lodash.throttle": "4.1.7",
"@types/react-dom": "18.0.11",
"jsdom": "21.1.1",
"lodash.throttle": "4.1.1",
"react": "18.2.0",
"react-dom": "18.2.0",
"react-test-renderer": "18.2.0",
"size-limit": "8.2.4",
"tsconfig": "workspace:*",
"tsup": "6.7.0",
"typescript": "5.0.4",
"vite": "4.2.1",
"vitest": "0.30.1",
"zustand": "4.3.7"
},
Expand Down
9 changes: 3 additions & 6 deletions packages/zundo/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,23 +40,20 @@ const zundoImpl =
type TState = ReturnType<typeof config>;
type StoreAddition = StoreApi<TemporalState<TState>>;

const temporalStore = createVanillaTemporal<TState>(set, get, {
partialize,
...restOptions,
});
const temporalStore = createVanillaTemporal<TState>(set, get, partialize, restOptions);

const store = _store as Mutate<
StoreApi<TState>,
[['temporal', StoreAddition]]
>;
const { setState } = store;
const setState = store.setState;

// TODO: should temporal be only temporalStore.getState()?
// We can hide the rest of the store in the secret internals.
store.temporal = temporalStore;

const curriedUserLandSet = userlandSetFactory(
temporalStore.getState().__internal.handleUserSet,
temporalStore.getState().__handleUserSet,
);

const modifiedSetState: typeof setState = (state, replace) => {
Expand Down
Loading

0 comments on commit 2145dfe

Please sign in to comment.