Skip to content

Commit 8efac02

Browse files
committed
Support nullable values with explicit delete() in storage API
- add delete(key) to StorageInstance - make set(key, value) store null as a real value - notify subscribers with undefined when keys are deleted - update tests and docs for the new contract
1 parent a639dde commit 8efac02

4 files changed

Lines changed: 39 additions & 14 deletions

File tree

packages/docs/utils/storage/createStorage.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ storage.set('theme', 'dark'); // ✅ typed
5353
// @errors: 2345
5454
storage.set('theme', 'blue'); // ❌ type error
5555
storage.get('user'); // { name: string; id: number } | undefined
56+
storage.delete('user');
5657
```
5758

5859
## Parameters
@@ -68,11 +69,12 @@ A `StorageInstance<T>` with the following methods:
6869

6970
- **`get(key)`** — Get the value for a key. Returns `undefined` if not set or if the stored value is corrupted.
7071
- **`get(key, defaultValue)`** — Get the value for a key, returning `defaultValue` if not set.
71-
- **`set(key, value)`** — Set a value. Pass `null` to remove the key.
72+
- **`set(key, value)`** — Set a value. `null` is stored as a real value when your type allows it.
73+
- **`delete(key)`** — Remove a key from storage. Subscribers are notified with `undefined`.
7274
- **`has(key)`** — Check if a key exists in storage.
7375
- **`keys()`** — List all keys managed by this instance. When a `prefix` is set, only prefixed keys are returned (with the prefix stripped).
7476
- **`clear()`** — Remove all entries. When a `prefix` is set, only prefixed keys are cleared — other keys in the same provider are left untouched. Subscribers are notified with `undefined`.
75-
- **`subscribe(key, callback)`** — Subscribe to changes on a key. Returns an unsubscribe function. Listeners are notified on both local changes and external sync events (cross-tab, navigation).
77+
- **`subscribe(key, callback)`** — Subscribe to changes on a key. Returns an unsubscribe function. Listeners are notified on both local changes and external sync events (cross-tab, navigation). `undefined` means the key is absent.
7678
- **`destroy()`** — Clean up all listeners and event handlers.
7779

7880
::: warning Cleanup

packages/js-toolkit/utils/storage/StorageStore.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,17 @@ export class StorageStore<T extends Record<string, any> = Record<string, any>>
4040
return defaultValue;
4141
}
4242

43-
set<K extends keyof T>(key: K, value: T[K] | null): void {
43+
set<K extends keyof T>(key: K, value: T[K]): void {
4444
const resolved = this.resolveKey(key as string);
45-
if (value === null) {
46-
this.provider.remove(resolved);
47-
} else {
48-
this.provider.set(resolved, this.serializer.serialize(value));
49-
}
50-
45+
this.provider.set(resolved, this.serializer.serialize(value));
5146
this.listeners.get(key)?.forEach((callback) => callback(value));
5247
}
5348

49+
delete<K extends keyof T>(key: K): void {
50+
this.provider.remove(this.resolveKey(key as string));
51+
this.listeners.get(key)?.forEach((callback) => callback(undefined));
52+
}
53+
5454
has<K extends keyof T>(key: K): boolean {
5555
return this.provider.has(this.resolveKey(key as string));
5656
}

packages/js-toolkit/utils/storage/types.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@ export interface StorageProvider {
1212
export interface StorageInstance<T = any> {
1313
get<K extends keyof T>(key: K): T[K] | undefined;
1414
get<K extends keyof T>(key: K, defaultValue: T[K]): T[K];
15-
set<K extends keyof T>(key: K, value: T[K] | null): void;
15+
set<K extends keyof T>(key: K, value: T[K]): void;
16+
delete<K extends keyof T>(key: K): void;
1617
has<K extends keyof T>(key: K): boolean;
1718
keys(): (keyof T)[];
1819
clear(): void;

packages/tests/utils/storage.spec.ts

Lines changed: 26 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -58,14 +58,23 @@ describe('Storage utilities', () => {
5858
expect(storage.get('theme', 'light')).toBe('dark');
5959
});
6060

61-
it('should remove key when set to null', () => {
61+
it('should support storing null as a value', () => {
62+
type Storage = { theme: string | null };
63+
const storage = createLocalStorage<Storage>();
64+
65+
storage.set('theme', null);
66+
expect(storage.get('theme')).toBeNull();
67+
expect(localStorage.getItem('theme')).toBe('null');
68+
});
69+
70+
it('should remove key when delete() is called', () => {
6271
type Storage = { theme: string };
6372
const storage = createLocalStorage<Storage>();
6473

6574
storage.set('theme', 'dark');
6675
expect(storage.get('theme')).toBe('dark');
6776

68-
storage.set('theme', null);
77+
storage.delete('theme');
6978
expect(storage.get('theme')).toBeUndefined();
7079
expect(localStorage.getItem('theme')).toBeNull();
7180
});
@@ -106,6 +115,19 @@ describe('Storage utilities', () => {
106115
expect(callback).toHaveBeenCalledTimes(1);
107116
});
108117

118+
it('should notify subscribers with undefined on delete()', () => {
119+
type Storage = { theme: string | null };
120+
const storage = createLocalStorage<Storage>();
121+
const callback = vi.fn();
122+
123+
storage.subscribe('theme', callback);
124+
storage.set('theme', null);
125+
expect(callback).toHaveBeenCalledWith(null);
126+
127+
storage.delete('theme');
128+
expect(callback).toHaveBeenLastCalledWith(undefined);
129+
});
130+
109131
it('should use custom serializer', () => {
110132
type Storage = { count: string };
111133
const serializer = {
@@ -313,7 +335,7 @@ describe('Storage utilities', () => {
313335
storage.set('theme', 'dark');
314336
expect(localStorage.getItem('myapp:theme')).toBe(JSON.stringify('dark'));
315337

316-
storage.set('theme', null);
338+
storage.delete('theme');
317339
expect(localStorage.getItem('myapp:theme')).toBeNull();
318340
});
319341
});
@@ -326,7 +348,7 @@ describe('Storage utilities', () => {
326348
expect(storage.has('theme')).toBe(false);
327349
storage.set('theme', 'dark');
328350
expect(storage.has('theme')).toBe(true);
329-
storage.set('theme', null);
351+
storage.delete('theme');
330352
expect(storage.has('theme')).toBe(false);
331353
});
332354

0 commit comments

Comments
 (0)