Skip to content

Commit 7f7cdf3

Browse files
feat(store): expose reactive canUndo/canRedo getters on useTravelStore controls (#13)
* feat(store): expose reactive canUndo/canRedo getters on useTravelStore controls `controls.canBack()` / `canForward()` read during render (e.g. the documented `disabled={!controls.canBack()}` pattern) freeze under the React Compiler, which memoises the method call on the stable `controls` reference. Expose `canUndo` / `canRedo` as getters instead — like the existing `position` getter, property reads are re-evaluated every render and stay reactive. `controls` keeps a stable identity and the existing `canBack()` / `canForward()` methods are unchanged. * fix(store): keep controls own-enumerable, add useTravel canUndo/canRedo + docs
1 parent cef3afa commit 7f7cdf3

4 files changed

Lines changed: 184 additions & 19 deletions

File tree

‎README.md‎

Lines changed: 16 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -81,14 +81,11 @@ export function Counter() {
8181
Increment
8282
</button>
8383

84-
<button onClick={() => controls.back()} disabled={!controls.canBack()}>
84+
<button onClick={() => controls.back()} disabled={!controls.canUndo}>
8585
Undo
8686
</button>
8787

88-
<button
89-
onClick={() => controls.forward()}
90-
disabled={!controls.canForward()}
91-
>
88+
<button onClick={() => controls.forward()} disabled={!controls.canRedo}>
9289
Redo
9390
</button>
9491

@@ -157,8 +154,10 @@ Common tuple members:
157154
| `controls.go(position)` | `(position: number) => void` | Jump to a specific history position |
158155
| `controls.reset()` | `() => void` | Reset to the initial state and clear history |
159156
| `controls.rebase()` | `() => void` | Make the current state the new baseline and discard past and future history |
160-
| `controls.canBack()` | `() => boolean` | Whether undo is possible |
161-
| `controls.canForward()` | `() => boolean` | Whether redo is possible |
157+
| `controls.canBack()` | `() => boolean` | Imperative predicate — whether undo is possible (use in event handlers) |
158+
| `controls.canForward()` | `() => boolean` | Imperative predicate — whether redo is possible (use in event handlers) |
159+
| `controls.canUndo` | `boolean` | Render-safe reactive flag — whether undo is possible (read during render) |
160+
| `controls.canRedo` | `boolean` | Render-safe reactive flag — whether redo is possible (read during render) |
162161

163162
When `autoArchive: false`, the controls also include:
164163

@@ -167,6 +166,15 @@ When `autoArchive: false`, the controls also include:
167166
| `controls.archive()` | `() => void` | Commit the current working state as the next undo step |
168167
| `controls.canArchive()` | `() => boolean` | Whether there are unarchived changes |
169168

169+
#### Checking undo/redo availability: getters vs methods
170+
171+
`canUndo` / `canRedo` are reactive **getters** and `canBack()` / `canForward()` are **methods**. They report the same thing, but are meant for different contexts:
172+
173+
- **During render** (e.g. `disabled={!controls.canUndo}`), use the `canUndo` / `canRedo` getters. They are re-read on every render, so they stay correct — including under the [React Compiler](https://react.dev/learn/react-compiler), which memoises method calls on the stable `controls` reference and would otherwise freeze `controls.canBack()` at its first-render value.
174+
- **In event handlers or outside render** (e.g. `onClick={() => controls.canBack() && controls.back()}`), the `canBack()` / `canForward()` methods are fine.
175+
176+
The methods are kept for backward compatibility and imperative use; the getters are the render-safe form.
177+
170178
### `useTravelStore(travels)`
171179

172180
Subscribes React to an existing immutable `Travels` instance without creating a new store.
@@ -198,7 +206,7 @@ export function Counter() {
198206
>
199207
Increment
200208
</button>
201-
<button onClick={() => controls.back()} disabled={!controls.canBack()}>
209+
<button onClick={() => controls.back()} disabled={!controls.canUndo}>
202210
Undo
203211
</button>
204212
</div>

‎src/index.ts‎

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,14 @@ import { useSyncExternalStore } from 'use-sync-external-store/shim';
1313

1414
export type { TravelPatches };
1515

16+
/** Adds reactive `canUndo` / `canRedo` availability flags to a controls type. */
17+
type WithUndoRedoFlags<C> = C & {
18+
/** Whether undo is possible. Render-safe reactive getter — read this during render. */
19+
readonly canUndo: boolean;
20+
/** Whether redo is possible. Render-safe reactive getter — read this during render. */
21+
readonly canRedo: boolean;
22+
};
23+
1624
type Result<
1725
S,
1826
F extends boolean,
@@ -21,9 +29,11 @@ type Result<
2129
> = [
2230
Value<S, F>,
2331
(updater: Updater<S>) => void,
24-
A extends false
25-
? RebasableManualTravelsControls<S, F, P>
26-
: RebasableTravelsControls<S, F, P>,
32+
WithUndoRedoFlags<
33+
A extends false
34+
? RebasableManualTravelsControls<S, F, P>
35+
: RebasableTravelsControls<S, F, P>
36+
>,
2737
];
2838

2939
type StoreControls<
@@ -35,6 +45,14 @@ type StoreControls<
3545
? RebasableTravelsControls<S, F, P>
3646
: RebasableManualTravelsControls<S, F, P>;
3747

48+
/** {@link StoreControls} plus reactive `canUndo` / `canRedo` flags. */
49+
type StoreControlsWithFlags<
50+
S,
51+
F extends boolean,
52+
A extends boolean,
53+
P extends PatchesOption = {},
54+
> = WithUndoRedoFlags<StoreControls<S, F, A, P>>;
55+
3856
/**
3957
* Creates a component-scoped {@link Travels} instance with undo/redo support and returns its reactive API.
4058
*
@@ -54,7 +72,11 @@ type StoreControls<
5472
*/
5573
export function useTravel<S, F extends boolean>(
5674
initialState: S
57-
): [Value<S, F>, (updater: Updater<S>) => void, RebasableTravelsControls<S, F>];
75+
): [
76+
Value<S, F>,
77+
(updater: Updater<S>) => void,
78+
WithUndoRedoFlags<RebasableTravelsControls<S, F>>,
79+
];
5880
export function useTravel<
5981
S,
6082
F extends boolean,
@@ -68,7 +90,7 @@ export function useTravel<
6890
): [
6991
Value<S, F>,
7092
(updater: Updater<S>) => void,
71-
RebasableTravelsControls<S, F, P>,
93+
WithUndoRedoFlags<RebasableTravelsControls<S, F, P>>,
7294
];
7395
export function useTravel<
7496
S,
@@ -83,7 +105,7 @@ export function useTravel<
83105
): [
84106
Value<S, F>,
85107
(updater: Updater<S>) => void,
86-
RebasableManualTravelsControls<S, F, P>,
108+
WithUndoRedoFlags<RebasableManualTravelsControls<S, F, P>>,
87109
];
88110
export function useTravel<
89111
S,
@@ -191,6 +213,12 @@ export function useTravel<
191213
go: (position: number) => baseControls.go(position),
192214
canBack: () => baseControls.canBack(),
193215
canForward: () => baseControls.canForward(),
216+
get canUndo() {
217+
return baseControls.canBack();
218+
},
219+
get canRedo() {
220+
return baseControls.canForward();
221+
},
194222
rebase: () => baseControls.rebase(),
195223
// Always include archive and canArchive methods for compatibility
196224
// Even in autoArchive mode, archive() can be called (but will warn)
@@ -237,7 +265,11 @@ export function useTravelStore<
237265
P extends PatchesOption = {},
238266
>(
239267
travels: Travels<S, F, A, P>
240-
): [Value<S, F>, (updater: Updater<S>) => void, StoreControls<S, F, A, P>] {
268+
): [
269+
Value<S, F>,
270+
(updater: Updater<S>) => void,
271+
StoreControlsWithFlags<S, F, A, P>,
272+
] {
241273
const isMutable = Boolean((travels as any)?.mutable);
242274

243275
if (isMutable) {
@@ -254,9 +286,18 @@ export function useTravelStore<
254286
(updater: Updater<S>) => travels.setState(updater),
255287
[travels]
256288
);
257-
const controls = useMemo<StoreControls<S, F, A, P>>(
258-
() => travels.getControls(),
259-
[travels]
260-
);
289+
const controls = useMemo<StoreControlsWithFlags<S, F, A, P>>(() => {
290+
const base = travels.getControls();
291+
// Copy the base's own descriptors (rather than Object.create / spread) so the existing
292+
// methods stay own-enumerable, then add canUndo/canRedo as reactive getters.
293+
return Object.defineProperties(
294+
{},
295+
{
296+
...Object.getOwnPropertyDescriptors(base),
297+
canUndo: { get: () => base.canBack(), enumerable: true },
298+
canRedo: { get: () => base.canForward(), enumerable: true },
299+
}
300+
) as StoreControlsWithFlags<S, F, A, P>;
301+
}, [travels]);
261302
return [state as Value<S, F>, setState, controls];
262303
}

‎test/use-travel-flags.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { describe, it, expect } from 'vitest';
2+
import { act, renderHook } from '@testing-library/react';
3+
import { useTravel } from '../src/index';
4+
5+
describe('useTravel canUndo / canRedo flags', () => {
6+
it('exposes reactive canUndo / canRedo getters while keeping the methods', () => {
7+
const { result } = renderHook(() => useTravel({ count: 0 }));
8+
9+
let [, setState, controls] = result.current;
10+
expect(controls.canUndo).toBe(false);
11+
expect(controls.canRedo).toBe(false);
12+
13+
act(() =>
14+
setState((draft) => {
15+
draft.count = 1;
16+
})
17+
);
18+
[, setState, controls] = result.current;
19+
20+
expect(controls.canUndo).toBe(true);
21+
expect(controls.canRedo).toBe(false);
22+
23+
expect(controls.canBack()).toBe(true);
24+
expect(controls.canForward()).toBe(false);
25+
26+
act(() => controls.back());
27+
[, , controls] = result.current;
28+
29+
expect(controls.canUndo).toBe(false);
30+
expect(controls.canRedo).toBe(true);
31+
});
32+
33+
it('keeps controls members own-enumerable (Object.keys / spread)', () => {
34+
const { result } = renderHook(() => useTravel({ count: 0 }));
35+
const [, , controls] = result.current;
36+
37+
const keys = Object.keys(controls);
38+
for (const member of [
39+
'position',
40+
'getHistory',
41+
'back',
42+
'forward',
43+
'reset',
44+
'go',
45+
'canBack',
46+
'canForward',
47+
'rebase',
48+
]) {
49+
expect(keys).toContain(member);
50+
}
51+
expect(keys).toContain('canUndo');
52+
expect(keys).toContain('canRedo');
53+
54+
const copy = { ...controls } as typeof controls;
55+
expect(typeof copy.back).toBe('function');
56+
expect(typeof copy.canBack).toBe('function');
57+
});
58+
});

‎test/use-travel-store.test.ts‎

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,64 @@ describe('useTravelStore', () => {
4646
expect(controls.getHistory()).toEqual(travels.getHistory());
4747
});
4848

49+
it('exposes reactive canUndo/canRedo getters while keeping controls stable', () => {
50+
const travels = new Travels({ count: 0 });
51+
52+
const { result } = renderHook(() => useTravelStore(travels));
53+
54+
const [, setState, controlsBefore] = result.current;
55+
expect(controlsBefore.canUndo).toBe(false);
56+
expect(controlsBefore.canRedo).toBe(false);
57+
58+
act(() =>
59+
setState((draft) => {
60+
draft.count = 1;
61+
})
62+
);
63+
64+
const [, , controlsAfter] = result.current;
65+
66+
expect(controlsAfter).toBe(controlsBefore);
67+
expect(controlsAfter.canUndo).toBe(true);
68+
expect(controlsAfter.canRedo).toBe(false);
69+
expect(controlsAfter.canBack()).toBe(true);
70+
71+
act(() => controlsAfter.back());
72+
const [, , controlsAfterUndo] = result.current;
73+
74+
expect(controlsAfterUndo.canUndo).toBe(false);
75+
expect(controlsAfterUndo.canRedo).toBe(true);
76+
});
77+
78+
it('keeps existing controls members own-enumerable (Object.keys / spread)', () => {
79+
const travels = new Travels({ count: 0 });
80+
81+
const { result } = renderHook(() => useTravelStore(travels));
82+
const [, , controls] = result.current;
83+
84+
const keys = Object.keys(controls);
85+
for (const member of [
86+
'position',
87+
'getHistory',
88+
'back',
89+
'forward',
90+
'reset',
91+
'go',
92+
'canBack',
93+
'canForward',
94+
'rebase',
95+
]) {
96+
expect(keys).toContain(member);
97+
}
98+
expect(keys).toContain('canUndo');
99+
expect(keys).toContain('canRedo');
100+
101+
const copy = { ...controls } as typeof controls;
102+
expect(typeof copy.back).toBe('function');
103+
expect(typeof copy.canBack).toBe('function');
104+
expect(copy.canUndo).toBe(false);
105+
});
106+
49107
it('exposes manual archive controls when autoArchive is disabled', () => {
50108
const travels = new Travels(
51109
{ todos: [] as string[] },

0 commit comments

Comments
 (0)