Skip to content

Commit 2145dfe

Browse files
authored
restructure __internals to shave bytes; add react tests (#82)
1 parent 86e2b62 commit 2145dfe

12 files changed

+966
-134
lines changed

apps/web/pages/reactive.tsx

+97
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
import { TemporalState, temporal } from 'zundo';
2+
import { StoreApi, useStore, create } from 'zustand';
3+
4+
interface MyState {
5+
bears: number;
6+
increment: () => void;
7+
decrement: () => void;
8+
}
9+
10+
const useMyStore = create(
11+
temporal<MyState>((set) => ({
12+
bears: 0,
13+
increment: () => set((state) => ({ bears: state.bears + 1 })),
14+
decrement: () => set((state) => ({ bears: state.bears - 1 })),
15+
})),
16+
);
17+
18+
type ExtractState<S> = S extends {
19+
getState: () => infer T;
20+
} ? T : never;
21+
type ReadonlyStoreApi<T> = Pick<StoreApi<T>, 'getState' | 'subscribe'>;
22+
type WithReact<S extends ReadonlyStoreApi<unknown>> = S & {
23+
getServerState?: () => ExtractState<S>;
24+
};
25+
26+
const useTemporalStore = <S extends WithReact<StoreApi<TemporalState<MyState>>>, U>(
27+
selector: (state: ExtractState<S>) => U,
28+
equality?: (a: U, b: U) => boolean,
29+
): U => {
30+
const state = useStore(useMyStore.temporal as any, selector, equality);
31+
return state
32+
}
33+
34+
const HistoryBar = () => {
35+
const futureStates = useTemporalStore((state) => state.futureStates);
36+
const pastStates = useTemporalStore((state) => state.pastStates);
37+
return (
38+
<div>
39+
past states: {JSON.stringify(pastStates)}
40+
<br />
41+
future states: {JSON.stringify(futureStates)}
42+
<br />
43+
</div>
44+
);
45+
};
46+
47+
const UndoBar = () => {
48+
const { undo, redo } = useTemporalStore((state) => ({
49+
undo: state.undo,
50+
redo: state.redo,
51+
}));
52+
return (
53+
<div>
54+
<button onClick={() => undo()}>undo</button>
55+
<button onClick={() => redo()}>redo</button>
56+
</div>
57+
);
58+
};
59+
60+
const StateBar = () => {
61+
const store = useMyStore();
62+
const { bears, increment, decrement } = store;
63+
return (
64+
<div>
65+
current state: {JSON.stringify(store)}
66+
<br />
67+
<br />
68+
bears: {bears}
69+
<br />
70+
<button onClick={increment}>increment</button>
71+
<button onClick={decrement}>decrement</button>
72+
</div>
73+
);
74+
};
75+
76+
const App = () => {
77+
return (
78+
<div>
79+
<h1>
80+
{' '}
81+
<span role="img" aria-label="bear">
82+
🐻
83+
</span>{' '}
84+
<span role="img" aria-label="recycle">
85+
♻️
86+
</span>{' '}
87+
Zundo!
88+
</h1>
89+
<StateBar />
90+
<br />
91+
<UndoBar />
92+
<HistoryBar />
93+
</div>
94+
);
95+
};
96+
97+
export default App;

apps/web/tsconfig.json

+4-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
{
22
"extends": "tsconfig/nextjs.json",
33
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx"],
4-
"exclude": ["node_modules"]
4+
"exclude": ["node_modules"],
5+
"compilerOptions": {
6+
"jsx": "react-jsx"
7+
}
58
}

packages/zundo/__tests__/createVanillaTemporal.test.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ describe('createVanillaTemporal', () => {
2727
};
2828
});
2929

30-
const temporalStore = createVanillaTemporal(store.setState, store.getState);
30+
const temporalStore = createVanillaTemporal(store.setState, store.getState, (state) => state);
3131
const { undo, redo, clear, pastStates, futureStates } =
3232
temporalStore.getState();
3333
it('should have the objects defined', () => {

packages/zundo/__tests__/options.test.ts

+22-27
Original file line numberDiff line numberDiff line change
@@ -310,7 +310,7 @@ describe('Middleware options', () => {
310310

311311
it('should call a new onSave function after being set', () => {
312312
global.console.info = vi.fn();
313-
global.console.log = vi.fn();
313+
global.console.warn = vi.fn();
314314
global.console.error = vi.fn();
315315
const storeWithOnSave = createVanillaStore({
316316
onSave: (pastStates) => {
@@ -325,11 +325,11 @@ describe('Middleware options', () => {
325325
});
326326
expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(2);
327327
expect(console.info).toHaveBeenCalledTimes(2);
328-
expect(console.log).toHaveBeenCalledTimes(0);
328+
expect(console.warn).toHaveBeenCalledTimes(0);
329329
expect(console.error).toHaveBeenCalledTimes(0);
330330
act(() => {
331331
setOnSave((pastStates, currentState) => {
332-
console.log(pastStates, currentState);
332+
console.warn(pastStates, currentState);
333333
});
334334
});
335335
act(() => {
@@ -338,7 +338,7 @@ describe('Middleware options', () => {
338338
});
339339
expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(4);
340340
expect(console.info).toHaveBeenCalledTimes(2);
341-
expect(console.log).toHaveBeenCalledTimes(2);
341+
expect(console.warn).toHaveBeenCalledTimes(2);
342342
expect(console.error).toHaveBeenCalledTimes(0);
343343
act(() => {
344344
setOnSave((pastStates, currentState) => {
@@ -351,7 +351,7 @@ describe('Middleware options', () => {
351351
});
352352
expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(6);
353353
expect(console.info).toHaveBeenCalledTimes(2);
354-
expect(console.log).toHaveBeenCalledTimes(2);
354+
expect(console.warn).toHaveBeenCalledTimes(2);
355355
expect(console.error).toHaveBeenCalledTimes(2);
356356
});
357357
});
@@ -425,17 +425,16 @@ describe('Middleware options', () => {
425425
expect(storeWithHandleSet.temporal.getState().futureStates.length).toBe(
426426
2,
427427
);
428-
expect(console.log).toHaveBeenCalledTimes(2);
428+
expect(console.warn).toHaveBeenCalledTimes(2);
429429
});
430430
});
431431

432432
describe('secret internals', () => {
433433
it('should have a secret internal state', () => {
434-
const { __internal } =
434+
const { __handleUserSet, __onSave } =
435435
store.temporal.getState() as TemporalStateWithInternals<MyState>;
436-
expect(__internal).toBeDefined();
437-
expect(__internal.handleUserSet).toBeInstanceOf(Function);
438-
expect(__internal.onSave).toBe(undefined);
436+
expect(__handleUserSet).toBeInstanceOf(Function);
437+
expect(__onSave).toBe(undefined);
439438
});
440439
describe('onSave', () => {
441440
it('should call onSave cb without adding a new state when onSave is set by user', () => {
@@ -446,13 +445,12 @@ describe('Middleware options', () => {
446445
console.error(pastStates, currentState);
447446
});
448447
});
449-
const { __internal } =
448+
const { __onSave } =
450449
store.temporal.getState() as TemporalStateWithInternals<MyState>;
451-
const { onSave } = __internal;
452450
act(() => {
453-
onSave(store.getState(), store.getState());
451+
__onSave(store.getState(), store.getState());
454452
});
455-
expect(__internal.onSave).toBeInstanceOf(Function);
453+
expect(__onSave).toBeInstanceOf(Function);
456454
expect(store.temporal.getState().pastStates.length).toBe(0);
457455
expect(console.error).toHaveBeenCalledTimes(1);
458456
});
@@ -463,11 +461,10 @@ describe('Middleware options', () => {
463461
console.info(pastStates);
464462
},
465463
});
466-
const { __internal } =
464+
const { __onSave } =
467465
storeWithOnSave.temporal.getState() as TemporalStateWithInternals<MyState>;
468-
const { onSave } = __internal;
469466
act(() => {
470-
onSave(storeWithOnSave.getState(), storeWithOnSave.getState());
467+
__onSave(storeWithOnSave.getState(), storeWithOnSave.getState());
471468
});
472469
expect(storeWithOnSave.temporal.getState().pastStates.length).toBe(0);
473470
expect(console.error).toHaveBeenCalledTimes(1);
@@ -483,7 +480,7 @@ describe('Middleware options', () => {
483480
act(() => {
484481
(
485482
storeWithOnSave.temporal.getState() as TemporalStateWithInternals<MyState>
486-
).__internal.onSave(
483+
).__onSave(
487484
storeWithOnSave.getState(),
488485
storeWithOnSave.getState(),
489486
);
@@ -501,7 +498,7 @@ describe('Middleware options', () => {
501498
act(() => {
502499
(
503500
storeWithOnSave.temporal.getState() as TemporalStateWithInternals<MyState>
504-
).__internal.onSave(store.getState(), store.getState());
501+
).__onSave(store.getState(), store.getState());
505502
});
506503
expect(store.temporal.getState().pastStates.length).toBe(0);
507504
expect(console.dir).toHaveBeenCalledTimes(1);
@@ -511,31 +508,29 @@ describe('Middleware options', () => {
511508

512509
describe('handleUserSet', () => {
513510
it('should update the temporal store with the pastState when called', () => {
514-
const { __internal } =
511+
const { __handleUserSet } =
515512
store.temporal.getState() as TemporalStateWithInternals<MyState>;
516-
const { handleUserSet } = __internal;
517513
act(() => {
518-
handleUserSet(store.getState());
514+
__handleUserSet(store.getState());
519515
});
520516
expect(store.temporal.getState().pastStates.length).toBe(1);
521517
});
522518

523519
it('should only update if the the status is tracking', () => {
524-
const { __internal } =
520+
const { __handleUserSet } =
525521
store.temporal.getState() as TemporalStateWithInternals<MyState>;
526-
const { handleUserSet } = __internal;
527522
act(() => {
528-
handleUserSet(store.getState());
523+
__handleUserSet(store.getState());
529524
});
530525
expect(store.temporal.getState().pastStates.length).toBe(1);
531526
act(() => {
532527
store.temporal.getState().pause();
533-
handleUserSet(store.getState());
528+
__handleUserSet(store.getState());
534529
});
535530
expect(store.temporal.getState().pastStates.length).toBe(1);
536531
act(() => {
537532
store.temporal.getState().resume();
538-
handleUserSet(store.getState());
533+
__handleUserSet(store.getState());
539534
});
540535
});
541536

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { describe, it, expect } from 'vitest';
2+
import React from 'react';
3+
import { fireEvent, render, screen } from '@testing-library/react';
4+
import Reactive from '../../../apps/web/pages/reactive';
5+
6+
describe('React Re-renders when state changes', () => {
7+
it('it', () => {
8+
const { queryByLabelText, getByLabelText, queryByText, getByText } = render(
9+
<Reactive />,
10+
);
11+
12+
expect(queryByText(/bears: 0/i)).toBeTruthy();
13+
expect(queryByText(/increment/i)).toBeTruthy();
14+
expect(queryByText(/past states: \[\]/i)).toBeTruthy();
15+
expect(queryByText(/future states: \[\]/i)).toBeTruthy();
16+
17+
const incrementButton = getByText(/increment/i);
18+
fireEvent.click(incrementButton);
19+
fireEvent.click(incrementButton);
20+
21+
expect(queryByText(/bears: 2/i)).toBeTruthy();
22+
expect(queryByText(/past states: \[{"bears":0},{"bears":1}\]/i)).toBeTruthy();
23+
expect(queryByText(/future states: \[\]/i)).toBeTruthy();
24+
25+
expect(queryByText(/undo/i, {
26+
selector: 'button',
27+
})).toBeTruthy();
28+
29+
const undoButton = getByText(/undo/i, {
30+
selector: 'button',
31+
});
32+
33+
fireEvent.click(undoButton);
34+
fireEvent.click(undoButton);
35+
36+
expect(queryByText(/bears: 0/i)).toBeTruthy();
37+
expect(queryByText(/past states: \[\]/i)).toBeTruthy();
38+
expect(queryByText(/future states: \[{"bears":2},{"bears":1}\]/i)).toBeTruthy();
39+
});
40+
});

packages/zundo/__tests__/setup.ts

+11
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import { expect, afterEach } from 'vitest';
2+
import { cleanup } from '@testing-library/react';
3+
import matchers from '@testing-library/jest-dom/matchers';
4+
5+
// extends Vitest's expect method with methods from react-testing-library
6+
expect.extend(matchers);
7+
8+
// runs a cleanup after each test case (e.g. clearing jsdom)
9+
afterEach(() => {
10+
cleanup();
11+
});

packages/zundo/package.json

+5
Original file line numberDiff line numberDiff line change
@@ -40,15 +40,20 @@
4040
},
4141
"devDependencies": {
4242
"@size-limit/preset-small-lib": "8.2.4",
43+
"@testing-library/jest-dom": "5.16.5",
44+
"@testing-library/react": "14.0.0",
4345
"@types/lodash.throttle": "4.1.7",
4446
"@types/react-dom": "18.0.11",
47+
"jsdom": "21.1.1",
4548
"lodash.throttle": "4.1.1",
4649
"react": "18.2.0",
4750
"react-dom": "18.2.0",
51+
"react-test-renderer": "18.2.0",
4852
"size-limit": "8.2.4",
4953
"tsconfig": "workspace:*",
5054
"tsup": "6.7.0",
5155
"typescript": "5.0.4",
56+
"vite": "4.2.1",
5257
"vitest": "0.30.1",
5358
"zustand": "4.3.7"
5459
},

packages/zundo/src/index.ts

+3-6
Original file line numberDiff line numberDiff line change
@@ -40,23 +40,20 @@ const zundoImpl =
4040
type TState = ReturnType<typeof config>;
4141
type StoreAddition = StoreApi<TemporalState<TState>>;
4242

43-
const temporalStore = createVanillaTemporal<TState>(set, get, {
44-
partialize,
45-
...restOptions,
46-
});
43+
const temporalStore = createVanillaTemporal<TState>(set, get, partialize, restOptions);
4744

4845
const store = _store as Mutate<
4946
StoreApi<TState>,
5047
[['temporal', StoreAddition]]
5148
>;
52-
const { setState } = store;
49+
const setState = store.setState;
5350

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

5855
const curriedUserLandSet = userlandSetFactory(
59-
temporalStore.getState().__internal.handleUserSet,
56+
temporalStore.getState().__handleUserSet,
6057
);
6158

6259
const modifiedSetState: typeof setState = (state, replace) => {

0 commit comments

Comments
 (0)