Skip to content

Commit a1769b1

Browse files
authored
Add unit tests for useFirstTimeUse hook and improve expiration handling (#673)
- Introduced comprehensive tests covering localStorage, sessionStorage, and IndexedDB behavior. - Refactored hook logic to support structured data with expiration timestamps. - Removed redundant cookie logic and streamlined persistence mechanisms.
1 parent 414f6a9 commit a1769b1

File tree

3 files changed

+563
-28
lines changed

3 files changed

+563
-28
lines changed

packages/frontend/src/components/Modals/FirstTimeModal/FirstTimeModal.tsx

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React from 'react';
22
import { ContextModalProps } from '@mantine/modals';
33
import { Button } from '@mantine/core';
4-
import { useCookies } from 'react-cookie';
54
import TextContent from '../../Content/TextContent';
65
import { FirstTimeModalConfig } from '../types';
76

@@ -16,13 +15,8 @@ export const FirstTimeModal = ({
1615
innerProps,
1716
}: ContextModalProps<FirstTimeModalProps>) => {
1817
const { config, markSeen } = innerProps;
19-
const [cookie, setCookie] = useCookies(['Gen3-first-time-use']);
2018

2119
const handleAccept = () => {
22-
// if (!cookie['Gen3-first-time-use']) {
23-
// const maxAge = 60 * 60 * 24 * (config?.expireDays ?? 365);
24-
// setCookie('Gen3-first-time-use', true, { maxAge });
25-
// }
2620
markSeen(config?.expireDays ?? 365);
2721
context.closeModal(id);
2822
};

packages/frontend/src/components/Modals/FirstTimeModal/hooks.ts

Lines changed: 74 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,32 @@
11
// hooks/useFirstTimeUse.ts
22
import { useEffect, useState } from 'react';
3-
import { useCookies } from 'react-cookie';
43
import { DEFAULT_EXPIRATION_DAYS, FTU_KEY, FTU_VALUE } from './constants';
54

65
type StorageError = {
76
store: string;
87
error: unknown;
98
};
109

10+
type StoredData = {
11+
value: string;
12+
expiresAt: number;
13+
};
14+
1115
function logStorageError({ store, error }: StorageError) {
1216
console.warn(`[useFirstTimeUse] Failed to access ${store}:`, error);
1317
}
1418

15-
function getCookieExpiry(days: number): Date {
16-
const expires = new Date();
17-
expires.setDate(expires.getDate() + days);
18-
return expires;
19+
function getExpirationTimestamp(days: number): number {
20+
return Date.now() + days * 24 * 60 * 60 * 1000;
1921
}
2022

21-
function setLocalStorage(): void {
23+
function setLocalStorage(expirationDays: number): void {
2224
try {
23-
localStorage.setItem(FTU_KEY, FTU_VALUE);
25+
const data: StoredData = {
26+
value: FTU_VALUE,
27+
expiresAt: getExpirationTimestamp(expirationDays),
28+
};
29+
localStorage.setItem(FTU_KEY, JSON.stringify(data));
2430
} catch (error) {
2531
logStorageError({ store: 'localStorage', error });
2632
}
@@ -34,7 +40,7 @@ function setSessionStorage(): void {
3440
}
3541
}
3642

37-
function setIndexedDB(): void {
43+
function setIndexedDB(expirationDays: number): void {
3844
try {
3945
const req = indexedDB.open('app_prefs', 1);
4046

@@ -49,7 +55,11 @@ function setIndexedDB(): void {
4955
try {
5056
const db = (e.target as IDBOpenDBRequest).result;
5157
const tx = db.transaction('flags', 'readwrite');
52-
tx.objectStore('flags').put(FTU_VALUE, FTU_KEY);
58+
const data: StoredData = {
59+
value: FTU_VALUE,
60+
expiresAt: getExpirationTimestamp(expirationDays),
61+
};
62+
tx.objectStore('flags').put(data, FTU_KEY);
5363
tx.onerror = () =>
5464
logStorageError({ store: 'indexedDB.transaction', error: tx.error });
5565
} catch (error) {
@@ -72,7 +82,34 @@ function setIndexedDB(): void {
7282

7383
function checkLocalStorage(): boolean {
7484
try {
75-
return localStorage.getItem(FTU_KEY) === FTU_VALUE;
85+
const item = localStorage.getItem(FTU_KEY);
86+
if (!item) return false;
87+
88+
// Try parsing as JSON (new format with expiration)
89+
try {
90+
const data = JSON.parse(item);
91+
// Check if it's an object with the expected structure
92+
if (typeof data === 'object' && data !== null && 'value' in data) {
93+
const storedData = data as StoredData;
94+
if (storedData.expiresAt && Date.now() > storedData.expiresAt) {
95+
// Expired, remove it
96+
localStorage.removeItem(FTU_KEY);
97+
return false;
98+
}
99+
return storedData.value === FTU_VALUE;
100+
}
101+
// Parsed as JSON but not the expected format - treat as legacy
102+
if (item === FTU_VALUE) {
103+
return true;
104+
}
105+
return false;
106+
} catch {
107+
// JSON parse failed - treat as legacy format (plain string)
108+
if (item === FTU_VALUE) {
109+
return true; // Still valid
110+
}
111+
return false;
112+
}
76113
} catch (error) {
77114
logStorageError({ store: 'localStorage.read', error });
78115
return false;
@@ -98,7 +135,29 @@ function checkIndexedDB(): Promise<boolean> {
98135
.transaction('flags')
99136
.objectStore('flags')
100137
.get(FTU_KEY);
101-
getReq.onsuccess = () => resolve(getReq.result === FTU_VALUE);
138+
getReq.onsuccess = () => {
139+
const result = getReq.result;
140+
if (!result) {
141+
resolve(false);
142+
return;
143+
}
144+
145+
// Check if it's the new format with expiration
146+
if (typeof result === 'object' && 'expiresAt' in result) {
147+
const data = result as StoredData;
148+
if (Date.now() > data.expiresAt) {
149+
// Expired, remove it
150+
const deleteTx = db.transaction('flags', 'readwrite');
151+
deleteTx.objectStore('flags').delete(FTU_KEY);
152+
resolve(false);
153+
return;
154+
}
155+
resolve(data.value === FTU_VALUE);
156+
} else {
157+
// Legacy format
158+
resolve(result === FTU_VALUE);
159+
}
160+
};
102161
getReq.onerror = () => {
103162
logStorageError({ store: 'indexedDB.get', error: getReq.error });
104163
resolve(false);
@@ -129,14 +188,12 @@ function checkIndexedDB(): Promise<boolean> {
129188
}
130189

131190
export function useFirstTimeUse() {
132-
const [cookies, setCookie] = useCookies([FTU_KEY]);
133191
const [showModal, setShowModal] = useState(false);
134192
const [isLoading, setIsLoading] = useState(true);
135193

136194
useEffect(() => {
137195
const checkAllStores = async (): Promise<boolean> => {
138-
// react-cookie handles the cookie check reactively
139-
if (cookies[FTU_KEY] === FTU_VALUE) return true;
196+
// Check localStorage and IndexedDB (persists across logout)
140197
if (checkLocalStorage()) return true;
141198
if (await checkIndexedDB()) return true;
142199
return false;
@@ -146,17 +203,12 @@ export function useFirstTimeUse() {
146203
setShowModal(!seen);
147204
setIsLoading(false);
148205
});
149-
}, [cookies]);
206+
}, []);
150207

151208
const markSeen = (expirationDays: number = DEFAULT_EXPIRATION_DAYS) => {
152-
setCookie(FTU_KEY, FTU_VALUE, {
153-
path: '/',
154-
expires: getCookieExpiry(expirationDays),
155-
sameSite: 'lax',
156-
});
157-
setLocalStorage();
209+
setLocalStorage(expirationDays);
158210
setSessionStorage();
159-
setIndexedDB();
211+
setIndexedDB(expirationDays);
160212
setShowModal(false);
161213
};
162214

0 commit comments

Comments
 (0)