Skip to content

Commit 6b101bc

Browse files
committed
refactor(utils): improve state handling with try-finally and enhance safety checks
- Wrapped state application logic in `try-finally` blocks to ensure cleanup (`isTimeTraveling`, `paused`, `hydrating`) regardless of errors. - Improved safety by skipping operations during invalid states (e.g., `hydration`/`disposed` in `persist`). - Added `shallowEqual` utility export to utils index.
1 parent 3056fb6 commit 6b101bc

4 files changed

Lines changed: 43 additions & 24 deletions

File tree

src/utils/devtools/devtools.ts

Lines changed: 16 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -90,22 +90,25 @@ export function devtools<T extends object>(
9090
const newState = JSON.parse(message.state) as Record<string, unknown>;
9191
isTimeTraveling = true;
9292

93-
// Apply state back to the proxy, skipping getters and methods
94-
for (const key of Object.keys(newState)) {
95-
// Skip getters
96-
if (findGetterDescriptor(proxyStore, key)?.get) continue;
97-
// Skip methods
98-
if (
99-
typeof (proxyStore as Record<string, unknown>)[key] === 'function'
100-
) {
101-
continue;
93+
try {
94+
// Apply state back to the proxy, skipping getters and methods
95+
for (const key of Object.keys(newState)) {
96+
// Skip getters
97+
if (findGetterDescriptor(proxyStore, key)?.get) continue;
98+
// Skip methods
99+
if (
100+
typeof (proxyStore as Record<string, unknown>)[key] ===
101+
'function'
102+
) {
103+
continue;
104+
}
105+
(proxyStore as Record<string, unknown>)[key] = newState[key];
102106
}
103-
(proxyStore as Record<string, unknown>)[key] = newState[key];
107+
} finally {
108+
isTimeTraveling = false;
104109
}
105-
106-
isTimeTraveling = false;
107110
} catch {
108-
isTimeTraveling = false;
111+
// JSON.parse failed — ignore corrupted DevTools state.
109112
}
110113
}
111114
}

src/utils/history/history.ts

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -96,16 +96,22 @@ export function withHistory<T extends object>(
9696
if (pointer <= 0) return;
9797
pointer--;
9898
paused = true;
99-
applySnapshot(history[pointer]);
100-
paused = false;
99+
try {
100+
applySnapshot(history[pointer]);
101+
} finally {
102+
paused = false;
103+
}
101104
},
102105

103106
redo() {
104107
if (pointer >= history.length - 1) return;
105108
pointer++;
106109
paused = true;
107-
applySnapshot(history[pointer]);
108-
paused = false;
110+
try {
111+
applySnapshot(history[pointer]);
112+
} finally {
113+
paused = false;
114+
}
109115
},
110116

111117
get canUndo() {

src/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,3 +22,4 @@ export type {
2222
} from './persist/persist';
2323
export {persist} from './persist/persist';
2424
export {subscribeKey} from './subscribe-key/subscribe-key';
25+
export {shallowEqual} from './equality/equality';

src/utils/persist/persist.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -211,8 +211,7 @@ function resolveProperties<T extends object>(
211211
for (const key of Object.keys(snap)) {
212212
// Skip getters (they live on the prototype, but snapshot installs them).
213213
// We check the original store's target for getter descriptors.
214-
if (findGetterDescriptor(Object.getPrototypeOf(proxyStore), key)?.get)
215-
continue;
214+
if (findGetterDescriptor(proxyStore, key)?.get) continue;
216215
// Skip functions (methods).
217216
const value = (proxyStore as Record<string, unknown>)[key];
218217
if (typeof value === 'function') continue;
@@ -308,6 +307,7 @@ export function persist<T extends object>(
308307
// ── State ────────────────────────────────────────────────────────────────
309308

310309
let disposed = false;
310+
let hydrating = false;
311311
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
312312
let hydratedFlag = false;
313313
let expiredFlag = false;
@@ -352,7 +352,7 @@ export function persist<T extends object>(
352352

353353
/** Schedule a debounced write (or write immediately if debounce is 0). */
354354
function scheduleWrite(): void {
355-
if (disposed) return;
355+
if (disposed || hydrating) return;
356356

357357
if (debounceMs <= 0) {
358358
void writeToStorage();
@@ -427,10 +427,12 @@ export function persist<T extends object>(
427427
let merged: Record<string, unknown>;
428428
if (typeof merge === 'function') {
429429
merged = merge(state, currentState);
430+
} else if (merge === 'replace') {
431+
// Only use persisted keys — new defaults not in storage are dropped.
432+
merged = state;
430433
} else {
431-
// Both 'shallow' and 'replace' assign persisted keys onto the store.
432-
// The difference is conceptual for nested objects, but at this level
433-
// both just assign the persisted value per key.
434+
// 'shallow': persisted values overwrite current, but properties not
435+
// in storage keep their current (default) value.
434436
merged = {...currentState, ...state};
435437
}
436438

@@ -446,7 +448,12 @@ export function persist<T extends object>(
446448
async function hydrateFromStorage(): Promise<void> {
447449
const raw = await storage.getItem(name);
448450
if (raw !== null) {
449-
applyPersistedState(raw);
451+
hydrating = true;
452+
try {
453+
applyPersistedState(raw);
454+
} finally {
455+
hydrating = false;
456+
}
450457
}
451458
}
452459

@@ -539,10 +546,12 @@ export function persist<T extends object>(
539546
},
540547

541548
async clear() {
549+
if (disposed) return;
542550
await storage.removeItem(name);
543551
},
544552

545553
async rehydrate() {
554+
expiredFlag = false;
546555
await hydrateFromStorage();
547556
if (!hydratedFlag) {
548557
hydratedFlag = true;

0 commit comments

Comments
 (0)