Skip to content

Commit c9086ad

Browse files
committed
fix(storage): resolve providers from global context
1 parent 3973aa0 commit c9086ad

3 files changed

Lines changed: 260 additions & 72 deletions

File tree

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

Lines changed: 103 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,164 +1,200 @@
1-
import { hasWindow } from '../has.js';
21
import type { StorageProvider, UrlProviderOptions } from './types.js';
32

3+
function getGlobalStorage(name: 'localStorage' | 'sessionStorage'): Storage | undefined {
4+
return typeof globalThis !== 'undefined' && name in globalThis
5+
? globalThis[name]
6+
: undefined;
7+
}
8+
9+
function getStorageKeys(storage: Storage | undefined): string[] {
10+
if (!storage) {
11+
return [];
12+
}
13+
14+
return Array.from({ length: storage.length }, (_, index) => storage.key(index)).filter(
15+
(key): key is string => key !== null,
16+
);
17+
}
18+
19+
function createNoopProvider(): StorageProvider {
20+
return {
21+
get: () => null,
22+
set: () => {},
23+
remove: () => {},
24+
has: () => false,
25+
keys: () => [],
26+
clear: () => {},
27+
};
28+
}
29+
30+
function getBrowserContext(): Pick<Window, 'location' | 'history'> | undefined {
31+
if (typeof globalThis === 'undefined' || !('window' in globalThis)) {
32+
return undefined;
33+
}
34+
35+
const { window } = globalThis;
36+
37+
if (!window || !window.location || !window.history) {
38+
return undefined;
39+
}
40+
41+
return {
42+
location: window.location,
43+
history: window.history,
44+
};
45+
}
46+
447
export const localStorageProvider: StorageProvider = {
548
syncEvent: 'storage',
6-
storageArea: hasWindow() ? localStorage : undefined,
49+
get storageArea() {
50+
return getGlobalStorage('localStorage');
51+
},
752
get(key: string): string | null {
8-
if (!hasWindow()) return null;
9-
return localStorage.getItem(key);
53+
return getGlobalStorage('localStorage')?.getItem(key) ?? null;
1054
},
1155
set(key: string, value: string): void {
12-
if (!hasWindow()) return;
13-
localStorage.setItem(key, value);
56+
getGlobalStorage('localStorage')?.setItem(key, value);
1457
},
1558
remove(key: string): void {
16-
if (!hasWindow()) return;
17-
localStorage.removeItem(key);
59+
getGlobalStorage('localStorage')?.removeItem(key);
1860
},
1961
has(key: string): boolean {
20-
if (!hasWindow()) return false;
21-
return localStorage.getItem(key) !== null;
62+
return getGlobalStorage('localStorage')?.getItem(key) !== null;
2263
},
2364
keys(): string[] {
24-
if (!hasWindow()) return [];
25-
return Object.keys(localStorage);
65+
return getStorageKeys(getGlobalStorage('localStorage'));
2666
},
2767
clear(): void {
28-
if (!hasWindow()) return;
29-
localStorage.clear();
68+
getGlobalStorage('localStorage')?.clear();
3069
},
3170
};
3271

3372
export const sessionStorageProvider: StorageProvider = {
73+
get storageArea() {
74+
return getGlobalStorage('sessionStorage');
75+
},
3476
get(key: string): string | null {
35-
if (!hasWindow()) return null;
36-
return sessionStorage.getItem(key);
77+
return getGlobalStorage('sessionStorage')?.getItem(key) ?? null;
3778
},
3879
set(key: string, value: string): void {
39-
if (!hasWindow()) return;
40-
sessionStorage.setItem(key, value);
80+
getGlobalStorage('sessionStorage')?.setItem(key, value);
4181
},
4282
remove(key: string): void {
43-
if (!hasWindow()) return;
44-
sessionStorage.removeItem(key);
83+
getGlobalStorage('sessionStorage')?.removeItem(key);
4584
},
4685
has(key: string): boolean {
47-
if (!hasWindow()) return false;
48-
return sessionStorage.getItem(key) !== null;
86+
return getGlobalStorage('sessionStorage')?.getItem(key) !== null;
4987
},
5088
keys(): string[] {
51-
if (!hasWindow()) return [];
52-
return Object.keys(sessionStorage);
89+
return getStorageKeys(getGlobalStorage('sessionStorage'));
5390
},
5491
clear(): void {
55-
if (!hasWindow()) return;
56-
sessionStorage.clear();
92+
getGlobalStorage('sessionStorage')?.clear();
5793
},
5894
};
5995

6096
export function createUrlSearchParamsProvider(
6197
providerOptions: UrlProviderOptions = {},
6298
): StorageProvider {
99+
const browserContext = getBrowserContext();
100+
101+
if (!browserContext) {
102+
return createNoopProvider();
103+
}
104+
63105
const { push = false } = providerOptions;
64106
const method = push ? 'pushState' : 'replaceState';
107+
const { location, history } = browserContext;
65108

66109
return {
67110
syncEvent: 'popstate',
68111
get(key: string): string | null {
69-
if (!hasWindow()) return null;
70-
return new URLSearchParams(window.location.search).get(key);
112+
return new URLSearchParams(location.search).get(key);
71113
},
72114
set(key: string, value: string): void {
73-
if (!hasWindow()) return;
74-
const params = new URLSearchParams(window.location.search);
115+
const params = new URLSearchParams(location.search);
75116
params.set(key, value);
76-
const newUrl = `${window.location.pathname}?${params.toString()}${window.location.hash}`;
77-
window.history[method]({}, '', newUrl);
117+
const newUrl = `${location.pathname}?${params.toString()}${location.hash}`;
118+
history[method]({}, '', newUrl);
78119
},
79120
remove(key: string): void {
80-
if (!hasWindow()) return;
81-
const params = new URLSearchParams(window.location.search);
121+
const params = new URLSearchParams(location.search);
82122
params.delete(key);
83123
const search = params.toString();
84-
const newUrl = `${window.location.pathname}${search ? `?${search}` : ''}${window.location.hash}`;
85-
window.history[method]({}, '', newUrl);
124+
const newUrl = `${location.pathname}${search ? `?${search}` : ''}${location.hash}`;
125+
history[method]({}, '', newUrl);
86126
},
87127
has(key: string): boolean {
88-
if (!hasWindow()) return false;
89-
return new URLSearchParams(window.location.search).has(key);
128+
return new URLSearchParams(location.search).has(key);
90129
},
91130
keys(): string[] {
92-
if (!hasWindow()) return [];
93-
return [...new URLSearchParams(window.location.search).keys()];
131+
return [...new URLSearchParams(location.search).keys()];
94132
},
95133
clear(): void {
96-
if (!hasWindow()) return;
97-
const newUrl = `${window.location.pathname}${window.location.hash}`;
98-
window.history[method]({}, '', newUrl);
134+
history[method]({}, '', `${location.pathname}${location.hash}`);
99135
},
100136
};
101137
}
102138

103139
export const urlSearchParamsProvider: StorageProvider = createUrlSearchParamsProvider();
104140

105-
function getParamsFromHash(): URLSearchParams | null {
106-
if (!hasWindow()) return null;
107-
return new URLSearchParams(window.location.hash.slice(1));
108-
}
109-
110141
export function createUrlSearchParamsInHashProvider(
111142
providerOptions: UrlProviderOptions = {},
112143
): StorageProvider {
144+
const browserContext = getBrowserContext();
145+
146+
if (!browserContext) {
147+
return createNoopProvider();
148+
}
149+
113150
const { push = false } = providerOptions;
151+
const { location, history } = browserContext;
152+
153+
function getParamsFromHash(): URLSearchParams {
154+
return new URLSearchParams(location.hash.slice(1));
155+
}
114156

115157
return {
116158
syncEvent: 'hashchange',
117159
get(key: string): string | null {
118-
return getParamsFromHash()?.get(key) ?? null;
160+
return getParamsFromHash().get(key);
119161
},
120162
set(key: string, value: string): void {
121163
const params = getParamsFromHash();
122-
if (!params) return;
123164
params.set(key, value);
124165
const newHash = params.toString();
166+
125167
if (push) {
126-
window.location.hash = newHash;
168+
location.hash = newHash;
127169
} else {
128-
window.history.replaceState(
129-
{},
130-
'',
131-
`${window.location.pathname}${window.location.search}#${newHash}`,
132-
);
170+
history.replaceState({}, '', `${location.pathname}${location.search}#${newHash}`);
133171
}
134172
},
135173
remove(key: string): void {
136174
const params = getParamsFromHash();
137-
if (!params) return;
138175
params.delete(key);
139176
const newHash = params.toString();
177+
140178
if (push) {
141-
window.location.hash = newHash;
179+
location.hash = newHash;
142180
} else {
143181
const url = newHash
144-
? `${window.location.pathname}${window.location.search}#${newHash}`
145-
: `${window.location.pathname}${window.location.search}`;
146-
window.history.replaceState({}, '', url);
182+
? `${location.pathname}${location.search}#${newHash}`
183+
: `${location.pathname}${location.search}`;
184+
history.replaceState({}, '', url);
147185
}
148186
},
149187
has(key: string): boolean {
150-
return getParamsFromHash()?.has(key) ?? false;
188+
return getParamsFromHash().has(key);
151189
},
152190
keys(): string[] {
153-
const params = getParamsFromHash();
154-
return params ? [...params.keys()] : [];
191+
return [...getParamsFromHash().keys()];
155192
},
156193
clear(): void {
157-
if (!hasWindow()) return;
158194
if (push) {
159-
window.location.hash = '';
195+
location.hash = '';
160196
} else {
161-
window.history.replaceState({}, '', `${window.location.pathname}${window.location.search}`);
197+
history.replaceState({}, '', `${location.pathname}${location.search}`);
162198
}
163199
},
164200
};
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// @vitest-environment node
2+
3+
import { afterEach, describe, expect, it, vi } from 'vitest';
4+
5+
function createMemoryStorage(): Storage {
6+
const map = new Map<string, string>();
7+
8+
return {
9+
get length() {
10+
return map.size;
11+
},
12+
clear() {
13+
map.clear();
14+
},
15+
getItem(key) {
16+
return map.get(key) ?? null;
17+
},
18+
key(index) {
19+
return [...map.keys()][index] ?? null;
20+
},
21+
removeItem(key) {
22+
map.delete(key);
23+
},
24+
setItem(key, value) {
25+
map.set(key, value);
26+
},
27+
};
28+
}
29+
30+
afterEach(() => {
31+
vi.unstubAllGlobals();
32+
vi.resetModules();
33+
});
34+
35+
describe('Storage utilities in a non-browser context', () => {
36+
it('should use globalThis.localStorage without window', async () => {
37+
vi.stubGlobal('localStorage', createMemoryStorage());
38+
vi.stubGlobal('sessionStorage', createMemoryStorage());
39+
vi.stubGlobal('window', undefined);
40+
41+
const { createLocalStorage } = await import('@studiometa/js-toolkit/utils');
42+
const storage = createLocalStorage<{ theme: string }>();
43+
44+
storage.set('theme', 'dark');
45+
46+
expect(storage.get('theme')).toBe('dark');
47+
expect(globalThis.localStorage.getItem('theme')).toBe(JSON.stringify('dark'));
48+
});
49+
50+
it('should use globalThis.sessionStorage without window', async () => {
51+
vi.stubGlobal('localStorage', createMemoryStorage());
52+
vi.stubGlobal('sessionStorage', createMemoryStorage());
53+
vi.stubGlobal('window', undefined);
54+
55+
const { createSessionStorage } = await import('@studiometa/js-toolkit/utils');
56+
const storage = createSessionStorage<{ token: string }>();
57+
58+
storage.set('token', 'abc123');
59+
60+
expect(storage.get('token')).toBe('abc123');
61+
expect(globalThis.sessionStorage.getItem('token')).toBe(JSON.stringify('abc123'));
62+
});
63+
64+
it('should return noop URL providers without window', async () => {
65+
vi.stubGlobal('localStorage', createMemoryStorage());
66+
vi.stubGlobal('sessionStorage', createMemoryStorage());
67+
vi.stubGlobal('window', undefined);
68+
69+
const {
70+
createUrlSearchParamsProvider,
71+
createUrlSearchParamsInHashProvider,
72+
createUrlSearchParamsStorage,
73+
createUrlSearchParamsInHashStorage,
74+
} = await import('@studiometa/js-toolkit/utils');
75+
76+
const searchProvider = createUrlSearchParamsProvider();
77+
const hashProvider = createUrlSearchParamsInHashProvider();
78+
const searchStorage = createUrlSearchParamsStorage<{ page: number }>();
79+
const hashStorage = createUrlSearchParamsInHashStorage<{ tab: string }>();
80+
81+
searchProvider.set('page', '1');
82+
hashProvider.set('tab', 'settings');
83+
searchStorage.set('page', 1);
84+
hashStorage.set('tab', 'settings');
85+
86+
expect(searchProvider.get('page')).toBeNull();
87+
expect(hashProvider.get('tab')).toBeNull();
88+
expect(searchProvider.has('page')).toBe(false);
89+
expect(hashProvider.has('tab')).toBe(false);
90+
expect(searchProvider.keys()).toEqual([]);
91+
expect(hashProvider.keys()).toEqual([]);
92+
expect(searchStorage.get('page')).toBeUndefined();
93+
expect(hashStorage.get('tab')).toBeUndefined();
94+
});
95+
});

0 commit comments

Comments
 (0)