Refactor language management to use storedSignal for persistence and update related components

This commit is contained in:
nessar committed 2025-11-11 14:42:55 +01:00
1 parent c1c82b5311
commit b66acc21ef
11 files changed
+647 -93

No files matched your search

+165
View File
@@ -0,0 +1,165 @@
# Stored Signals
This document explains how persistent state management works in this application using the `storedSignal` utility.
## Overview
The application uses a custom `storedSignal` utility to automatically persist signal values to localStorage. This provides a seamless way to maintain user preferences and state across browser sessions.
## What is storedSignal?
`storedSignal` is a utility function located in `src/app/shared/utils/stored-signal.ts` that creates Angular signals with automatic localStorage persistence. It combines the reactivity of Angular signals with the durability of browser storage.
### Key Features
- **Automatic Persistence**: Changes are automatically saved to localStorage with debouncing (default: 300ms)
- **SSR-Safe**: Works in server-side rendering environments where localStorage is unavailable
- **Type-Safe**: Full TypeScript support with generic types
- **Error Handling**: Gracefully handles localStorage errors and corrupted data
- **Initialization**: Automatically restores values from localStorage on creation
## Usage
### Basic Usage
```typescript
import { inject, Injector } from '@angular/core';
import { storedSignal } from '../shared/utils/stored-signal';
export class MyService {
private readonly injector = inject(Injector);
// Create a stored signal
public readonly mySetting = storedSignal<string>(
'my.setting.key', // localStorage key
'default value', // default/initial value
this.injector, // Angular injector
);
// Use it like a normal signal
changeValue() {
this.mySetting.set('new value'); // Automatically saved to localStorage
}
}
```
### With Custom Debounce
```typescript
public readonly fastSetting = storedSignal<number>(
'fast.setting',
0,
this.injector,
100 // Custom debounce in milliseconds
);
```
## Current Usage in the App
### 1. Randomizer Settings (`RandomizerStateService`)
**Location**: `src/app/services/randomizer-state.service.ts`
```typescript
// Blacklisted champions (fearless draft)
public readonly blacklistedChampions = storedSignal<string[]>(
'randomizer.blacklistedChampions',
[],
this.injector,
);
// Fearless draft enabled/disabled
public readonly fearlessDraftEnabled = storedSignal<boolean>(
'randomizer.fearlessDraftEnabled',
true,
this.injector,
);
```
**What's Stored**:
- List of blacklisted champion names (accumulated when fearless draft is enabled)
- Whether fearless draft mode is enabled
**Behavior**:
- When fearless draft is enabled, selected champions are automatically added to the blacklist
- Blacklist persists across sessions, so users can continue their fearless draft series
- When fearless draft is disabled, the blacklist is cleared
### 2. Language Preference (`LanguageService`)
**Location**: `src/app/services/language.service.ts`
```typescript
public readonly currentLang = storedSignal<string>(
'app.language',
this.getInitialLanguage(),
this.injector,
);
```
**What's Stored**:
- The user's selected language code (e.g., 'en', 'fr', 'zh')
**Behavior**:
- Language preference persists across sessions
- Automatically syncs with the TranslateService
- Falls back to browser language detection for first-time users
- Handles migration from old 'lang' localStorage key
## localStorage Keys
All stored values use namespaced keys to avoid conflicts:
| Key | Type | Purpose |
|-----|------|---------|
| `randomizer.blacklistedChampions` | `string[]` | List of blacklisted champion names |
| `randomizer.fearlessDraftEnabled` | `boolean` | Fearless draft mode toggle |
| `app.language` | `string` | User's language preference |
## Benefits
1. **User Experience**: Settings and preferences are preserved across sessions
2. **Fearless Draft**: Players can continue their draft series even after closing the browser
3. **Performance**: Debouncing prevents excessive localStorage writes
4. **Type Safety**: TypeScript ensures type correctness at compile time
5. **Maintainability**: Centralized storage logic in one utility function
## Best Practices
1. **Use Descriptive Keys**: Prefix keys with module/feature names (e.g., `randomizer.`, `app.`)
2. **Provide Sensible Defaults**: Always include a reasonable initial value
3. **Keep Data JSON-Serializable**: Store simple data structures (primitives, arrays, objects)
4. **Don't Store Sensitive Data**: localStorage is not secure; never store passwords or tokens
## Testing
When testing components or services that use `storedSignal`, ensure:
1. Clear localStorage before each test
2. Use `provideZonelessChangeDetection()` in TestBed setup
3. Call `TestBed.flushEffects()` after signal changes to trigger effects
4. Wait for debounce timeouts when testing persistence
Example:
```typescript
it('should persist value', (done) => {
service.mySetting.set('new value');
TestBed.flushEffects();
setTimeout(() => {
expect(localStorage.getItem('my.setting.key')).toBe('"new value"');
done();
}, 400); // Wait for 300ms debounce + buffer
});
```
## Future Enhancements
Potential improvements to consider:
- [ ] Add support for session storage
- [ ] Implement storage quota management
- [ ] Add encryption option for sensitive data
- [ ] Support for storage events (sync across tabs)
- [ ] Configurable serialization/deserialization functions
- [ ] Storage versioning and migration support
+4 -25
View File
@@ -4,7 +4,8 @@ import { MatIconModule } from '@angular/material/icon';
import { MatCardModule } from '@angular/material/card';
import { ChampionRandomizerComponent } from './components/champion-randomizer.component';
import { ImagePreloadService } from './services/image-preload.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { TranslateModule } from '@ngx-translate/core';
import { LanguageService } from './services/language.service';
@Component({
selector: 'app-root',
@@ -20,29 +21,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
styleUrls: ['./app.css'],
})
export class App {
// Initialize the image preload service
// Initialize services
private readonly imagePreload = inject(ImagePreloadService);
private readonly translate = inject(TranslateService);
constructor() {
// Initialize language: prefer saved value, otherwise try the user's
// browser language (navigator.languages / navigator.language), fall back to English.
this.translate.setDefaultLang('en');
let lang: string | null = null;
try {
lang = typeof localStorage !== 'undefined' ? localStorage.getItem('lang') : null;
} catch {
// Ignore localStorage errors (e.g., in private browsing mode)
}
if (!lang && typeof navigator !== 'undefined') {
const candidate =
(navigator.languages && navigator.languages[0]) || navigator.language || '';
if (candidate.startsWith('fr')) {
lang = 'fr';
} else {
lang = 'en';
}
}
this.translate.use(lang || 'en');
}
private readonly languageService = inject(LanguageService);
}
@@ -21,7 +21,7 @@
</div>
}
}
@if (state.blacklistedChampions().size === 0) {
@if (state.blacklistedChampions().length === 0) {
<p class="empty-message">{{ 'blacklist.empty' | translate }}</p>
}
</mat-dialog-content>
@@ -6,7 +6,8 @@ import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { SettingsDialogComponent } from '../settings-dialog/settings-dialog.component';
import { BlacklistDialogComponent } from '../blacklist-dialog/blacklist-dialog.component';
import { RandomizerStateService } from '../../services/randomizer-state.service';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { LanguageService } from '../../services/language.service';
import { TranslateModule } from '@ngx-translate/core';
@Component({
selector: 'app-control-header',
@@ -18,21 +19,7 @@ import { TranslateModule, TranslateService } from '@ngx-translate/core';
export class ControlHeaderComponent {
private readonly dialog = inject(MatDialog);
protected readonly state = inject(RandomizerStateService);
private readonly translate = inject(TranslateService);
// Expose current language for UI or binding
get currentLang(): string {
return this.translate.currentLang || this.translate.getDefaultLang() || 'en';
}
setLang(lang: string): void {
this.translate.use(lang);
try {
localStorage.setItem('lang', lang);
} catch {
// Ignore localStorage errors (e.g., in private browsing mode)
}
}
private readonly languageService = inject(LanguageService);
onRoll(): void {
this.state.rollAssignments();
@@ -64,8 +51,8 @@ export class ControlHeaderComponent {
// Update the service state immediately
this.state.setFearlessDraftEnabled(value);
},
currentLang: this.currentLang,
setLang: (lang: string) => this.setLang(lang),
currentLang: this.languageService.currentLang(),
setLang: (lang: string) => this.languageService.setLanguage(lang),
},
});
}
@@ -50,16 +50,9 @@ export class SettingsDialogComponent {
}
onLangChange(lang: string): void {
// If the parent provided a setter, call it; otherwise change directly
// Call the parent-provided setter
if (this.data.setLang) {
this.data.setLang(lang);
} else {
this.translate.use(lang);
try {
localStorage.setItem('lang', lang);
} catch {
// ignore
}
}
// update local copy
this.data.currentLang = lang;
@@ -274,7 +274,7 @@ describe('ChampionDataService', () => {
const cacheData = {
version: 1,
timestamp: Date.now() - 25 * 60 * 60 * 1000, // 25 hours ago
timestamp: Date.now() - 31 * 24 * 60 * 60 * 1000, // 31 days ago (expired)
champions: mockChampions,
};
+1 -1
View File
@@ -22,7 +22,7 @@ const POSITION_TO_LANE: Record<RemotePosition, Lane> = {
const CACHE_KEY = 'lol-champions-cache';
const CACHE_VERSION = 1;
const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const CACHE_EXPIRY_MS = 30 * 24 * 60 * 60 * 1000; // 30 days
interface CachedData {
version: number;
+92
View File
@@ -0,0 +1,92 @@
import { TestBed } from '@angular/core/testing';
import { provideZonelessChangeDetection } from '@angular/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { LanguageService } from './language.service';
describe('LanguageService', () => {
let service: LanguageService;
let translateService: TranslateService;
beforeEach(() => {
// Clear localStorage before each test
localStorage.clear();
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [LanguageService, provideZonelessChangeDetection()],
});
service = TestBed.inject(LanguageService);
translateService = TestBed.inject(TranslateService);
});
afterEach(() => {
localStorage.clear();
});
it('should be created', () => {
expect(service).toBeTruthy();
});
it('should initialize with default language', () => {
expect(service.currentLang()).toBe('en');
});
it('should change language', () => {
service.setLanguage('fr');
expect(service.currentLang()).toBe('fr');
// Flush effects to ensure the effect runs
TestBed.flushEffects();
// In zoneless mode, we need to check if translate was called
expect(translateService.currentLang).toBe('fr');
});
it('should persist language to localStorage', (done) => {
// Flush effects to ensure initialization is complete
TestBed.flushEffects();
service.setLanguage('zh');
expect(service.currentLang()).toBe('zh');
// Flush effects after setting the language to trigger the storedSignal effect
TestBed.flushEffects();
// Wait for debounce (300ms + buffer)
setTimeout(() => {
const stored = localStorage.getItem('app.language');
expect(stored).toBe('"zh"');
done();
}, 400);
});
it('should restore language from localStorage', () => {
localStorage.setItem('app.language', '"fr"');
// Create a new TestBed instance to test restoration
TestBed.resetTestingModule();
TestBed.configureTestingModule({
imports: [TranslateModule.forRoot()],
providers: [LanguageService, provideZonelessChangeDetection()],
});
const newService = TestBed.inject(LanguageService);
expect(newService.currentLang()).toBe('fr');
});
it('should sync with TranslateService', () => {
// The service uses the translate service in constructor and via effect
// In tests, just verify the current language matches
expect(translateService.currentLang).toBe(service.currentLang());
service.setLanguage('zh');
expect(service.currentLang()).toBe('zh');
// Flush effects to ensure the effect runs
TestBed.flushEffects();
// The effect should update the translate service in the next change detection
expect(translateService.currentLang).toBe('zh');
});
});
+74
View File
@@ -0,0 +1,74 @@
import { Injectable, inject, Injector, effect } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { storedSignal } from '../shared/utils/stored-signal';
/**
* Service to manage language preferences with persistence to localStorage.
* Uses storedSignal to automatically save and restore the selected language.
*/
@Injectable({
providedIn: 'root',
})
export class LanguageService {
private readonly translate = inject(TranslateService);
private readonly injector = inject(Injector);
// Store language preference using storedSignal
public readonly currentLang = storedSignal<string>(
'app.language',
this.getInitialLanguage(),
this.injector,
);
constructor() {
// Set default language
this.translate.setDefaultLang('en');
// Use the stored language
this.translate.use(this.currentLang());
// Sync language changes to translate service
effect(() => {
this.translate.use(this.currentLang());
});
}
/**
* Change the current language
*/
public setLanguage(lang: string): void {
this.currentLang.set(lang);
}
/**
* Get the initial language based on browser preferences or old localStorage key
*/
private getInitialLanguage(): string {
// Check for saved language in old key (for backward compatibility)
if (typeof localStorage !== 'undefined') {
try {
const savedLang = localStorage.getItem('lang');
if (savedLang) {
// Clean up old key
localStorage.removeItem('lang');
return savedLang;
}
} catch {
// Ignore localStorage errors
}
}
// Detect browser language
if (typeof navigator !== 'undefined') {
const candidate =
(navigator.languages && navigator.languages[0]) || navigator.language || '';
if (candidate.startsWith('fr')) {
return 'fr';
} else if (candidate.startsWith('zh')) {
return 'zh';
}
}
return 'en';
}
}
+64 -38
View File
@@ -1,7 +1,8 @@
import { computed, effect, inject, Injectable, signal } from '@angular/core';
import { computed, effect, inject, Injectable, Injector, signal } from '@angular/core';
import { Champion, Lane, LANES } from '../data/champions.data';
import { ChampionDataService } from './champion-data.service';
import { RandomNumber } from './random-number';
import { storedMapSignal, storedSetSignal, storedSignal } from '../shared/utils/stored-signal';
interface LaneAssignmentView {
readonly lane: Lane;
@@ -14,6 +15,7 @@ interface LaneAssignmentView {
})
export class RandomizerStateService {
private readonly championData = inject(ChampionDataService);
private readonly injector = inject(Injector);
private readonly laneTitleMap: Record<Lane, string> = {
top: 'Top Lane',
jungle: 'Jungle',
@@ -22,17 +24,44 @@ export class RandomizerStateService {
support: 'Support',
};
private previousChampionStamp = '';
private previousChampionStamp = storedSignal<string>(
'randomizer.previousChampionStamp',
'',
this.injector,
);
// Core state signals
public readonly disabledLanes = signal<Set<Lane>>(new Set());
public readonly disabledLanes = storedSetSignal<Lane>(
'randomizer.disabledLanes',
new Set(),
this.injector,
);
public readonly reRollBank = storedSignal<number>('randomizer.reRollBank', 0, this.injector);
public readonly reRollBankMax = storedSignal<number>(
'randomizer.reRollBankMax',
0,
this.injector,
);
// Persisted settings using storedSignal
public readonly blacklistedChampions = storedSignal<string[]>(
'randomizer.blacklistedChampions',
[],
this.injector,
);
public readonly fearlessDraftEnabled = storedSignal<boolean>(
'randomizer.fearlessDraftEnabled',
true,
this.injector,
);
public readonly assignments = storedMapSignal<Lane, Champion | null>(
'randomizer.assignments',
this.createEmptyAssignments(),
this.injector,
);
public readonly isShiftPressed = signal<boolean>(false);
public readonly reRollBank = signal<number>(0);
public readonly reRollBankMax = signal<number>(0);
public readonly showAndy = signal<boolean>(false);
public readonly blacklistedChampions = signal<Set<string>>(new Set());
public readonly fearlessDraftEnabled = signal<boolean>(true);
public readonly assignments = signal<Map<Lane, Champion | null>>(this.createEmptyAssignments());
// Computed values from champion data service
public readonly lanes = LANES;
@@ -83,7 +112,7 @@ export class RandomizerStateService {
!this.assignmentsArray().some(
(assignedChampion) =>
assignedChampion && assignedChampion.name === champion.name,
) && !blacklisted.has(champion.name),
) && !blacklisted.includes(champion.name),
) || [];
notUsed.set(lane, champion);
}
@@ -99,7 +128,7 @@ export class RandomizerStateService {
const championsForLane =
this.championsByLane()
.get(lane)
?.filter((champion) => blacklisted.has(champion.name)) || [];
?.filter((champion) => blacklisted.includes(champion.name)) || [];
result.set(lane, championsForLane);
}
@@ -142,13 +171,17 @@ export class RandomizerStateService {
const champions = this.champions();
if (champions.length === 0) {
this.assignments.set(this.createEmptyAssignments());
this.previousChampionStamp = '';
this.previousChampionStamp.set('');
return;
}
const stamp = champions.map((champion) => champion.id).join('|');
if (stamp !== this.previousChampionStamp) {
this.previousChampionStamp = stamp;
if (stamp !== this.previousChampionStamp()) {
this.previousChampionStamp.set(stamp);
this.rollAssignments();
}
if (this.assignments().size === 0) {
this.rollAssignments();
}
});
@@ -177,8 +210,25 @@ export class RandomizerStateService {
}
}
private updateBlacklist() {
// Add selected champions to blacklist for fearless draft (if enabled)
if (this.fearlessDraftEnabled()) {
this.blacklistedChampions.update((current) => {
const updated = [...current];
this.assignmentsArray().forEach((championName) => {
if (championName === null) return;
if (!updated.includes(championName.name)) {
updated.push(championName.name);
}
});
return updated;
});
}
}
// Actions
public rollAssignments(): void {
this.updateBlacklist();
const championsByLaneMap = this.notUsedChampions();
this.reRollBank.set(this.activatedLanesArray().length);
@@ -208,15 +258,6 @@ export class RandomizerStateService {
}
this.assignments.set(result);
// Add selected champions to blacklist for fearless draft (if enabled)
if (this.fearlessDraftEnabled()) {
this.blacklistedChampions.update((current) => {
const updated = new Set(current);
selected.forEach((championName) => updated.add(championName));
return updated;
});
}
}
public retryLoad(): void {
@@ -247,26 +288,11 @@ export class RandomizerStateService {
const champion = notUsedChampionsForLane[number];
// Get the old champion to remove from blacklist if being replaced
const oldChampion = this.assignments().get(lane);
this.assignments.update((current) => {
const newState = new Map(current);
newState.set(lane, champion);
return newState;
});
// Update blacklist only when fearless draft is enabled
if (this.fearlessDraftEnabled()) {
this.blacklistedChampions.update((current) => {
const updated = new Set(current);
if (oldChampion) {
updated.delete(oldChampion.name);
}
updated.add(champion.name);
return updated;
});
}
}
public toggleLane(lane: Lane): void {
@@ -314,7 +340,7 @@ export class RandomizerStateService {
}
public resetBlacklist(): void {
this.blacklistedChampions.set(new Set());
this.blacklistedChampions.set([]);
}
public setFearlessDraftEnabled(value: boolean): void {
+239 -1
View File
@@ -12,6 +12,24 @@ function isLocalStorageAvailable(): boolean {
}
}
/**
* Cache for stored signal values to avoid redundant localStorage reads.
* Maps storage keys to their parsed values.
*/
const signalCache = new Map<string, unknown>();
/**
* Cache for stored Map signal values to avoid redundant localStorage reads.
* Maps storage keys to their parsed Map instances.
*/
const mapSignalCache = new Map<string, Map<unknown, unknown>>();
/**
* Cache for stored Set signal values to avoid redundant localStorage reads.
* Maps storage keys to their parsed Set instances.
*/
const setSignalCache = new Map<string, Set<unknown>>();
/**
* Creates a signal that automatically persists its value to localStorage.
*
@@ -51,11 +69,16 @@ export function storedSignal<T>(
): WritableSignal<T> {
let parsedValue = initialValue;
if (isLocalStorageAvailable()) {
// Check cache first
if (signalCache.has(key)) {
parsedValue = signalCache.get(key) as T;
} else if (isLocalStorageAvailable()) {
try {
const storedValue = localStorage.getItem(key);
if (storedValue) {
parsedValue = JSON.parse(storedValue) as T;
// Cache the parsed value
signalCache.set(key, parsedValue);
}
} catch (error) {
console.warn(
@@ -95,6 +118,8 @@ export function storedSignal<T>(
if (isLocalStorageAvailable()) {
try {
localStorage.setItem(key, JSON.stringify(value));
// Update cache
signalCache.set(key, value);
} catch (error) {
console.warn(
`Failed to save value for key "${key}" to localStorage.`,
@@ -109,3 +134,216 @@ export function storedSignal<T>(
return s;
}
/**
* Creates a signal that automatically persists a Map to localStorage.
*
* The signal will:
* - Initialize with the stored Map from localStorage if available
* - Fall back to the provided initial Map if no stored value exists or parsing fails
* - Automatically sync changes to localStorage with debouncing (300ms delay)
* - Work safely in SSR and environments where localStorage is unavailable
*
* @template K - The type of the Map keys (must be JSON-serializable)
* @template V - The type of the Map values (must be JSON-serializable)
* @param key - The localStorage key used to store the Map
* @param initialValue - The default Map used when no stored value exists
* @param injector - The Angular injector (use inject(Injector) to get current context)
* @param debounceMs - Optional debounce delay in milliseconds (default: 300ms)
* @returns A writable signal containing a Map that persists to localStorage
*
* @example
* ```typescript
* const injector = inject(Injector);
* const userPrefs = storedMapSignal<string, boolean>('user.preferences', new Map(), injector);
* userPrefs.update(map => new Map(map).set('darkMode', true));
* console.log(userPrefs().get('darkMode')); // true
* ```
*
* @remarks
* - Requires an Injector to create the effect in the proper context
* - Keys and values must be JSON-serializable (no functions, circular references, etc.)
* - In SSR or when localStorage is unavailable, the signal works normally but won't persist
* - Corrupted localStorage data is automatically cleared and reset to initial Map
* - localStorage writes are debounced to improve performance with frequent updates
* - Maps are stored as arrays of [key, value] pairs in localStorage
*/
export function storedMapSignal<K, V>(
key: string,
initialValue: Map<K, V>,
injector: Injector,
debounceMs = 300,
): WritableSignal<Map<K, V>> {
let parsedValue = initialValue;
// Check cache first
if (mapSignalCache.has(key)) {
parsedValue = mapSignalCache.get(key) as Map<K, V>;
} else if (isLocalStorageAvailable()) {
try {
const storedValue = localStorage.getItem(key);
if (storedValue) {
const entries = JSON.parse(storedValue) as Array<[K, V]>;
parsedValue = new Map(entries);
// Cache the parsed Map
mapSignalCache.set(key, parsedValue as Map<unknown, unknown>);
}
} catch (error) {
console.warn(
`Failed to parse stored Map for key "${key}". Using initial value.`,
error,
);
try {
localStorage.removeItem(key);
} catch {
// Ignore removal errors
}
}
}
const s = signal(parsedValue);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let isFirstRun = true;
runInInjectionContext(injector, () => {
effect(() => {
const value = s();
// Skip the first execution to avoid writing back what we just read
if (isFirstRun) {
isFirstRun = false;
return;
}
// Clear any pending timeout
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
// Debounce the localStorage write
timeoutId = setTimeout(() => {
if (isLocalStorageAvailable()) {
try {
// Convert Map to array of entries for JSON serialization
const entries = Array.from(value.entries());
localStorage.setItem(key, JSON.stringify(entries));
// Update cache
mapSignalCache.set(key, value as Map<unknown, unknown>);
} catch (error) {
console.warn(`Failed to save Map for key "${key}" to localStorage.`, error);
}
}
timeoutId = null;
}, debounceMs);
});
});
return s;
}
/**
* Creates a signal that automatically persists a Set to localStorage.
*
* The signal will:
* - Initialize with the stored Set from localStorage if available
* - Fall back to the provided initial Set if no stored value exists or parsing fails
* - Automatically sync changes to localStorage with debouncing (300ms delay)
* - Work safely in SSR and environments where localStorage is unavailable
*
* @template T - The type of the Set elements (must be JSON-serializable)
* @param key - The localStorage key used to store the Set
* @param initialValue - The default Set used when no stored value exists
* @param injector - The Angular injector (use inject(Injector) to get current context)
* @param debounceMs - Optional debounce delay in milliseconds (default: 300ms)
* @returns A writable signal containing a Set that persists to localStorage
*
* @example
* ```typescript
* const injector = inject(Injector);
* const favorites = storedSetSignal<string>('user.favorites', new Set(), injector);
* favorites.update(set => new Set(set).add('item1'));
* console.log(favorites().has('item1')); // true
* ```
*
* @remarks
* - Requires an Injector to create the effect in the proper context
* - Elements must be JSON-serializable (no functions, circular references, etc.)
* - In SSR or when localStorage is unavailable, the signal works normally but won't persist
* - Corrupted localStorage data is automatically cleared and reset to initial Set
* - localStorage writes are debounced to improve performance with frequent updates
* - Sets are stored as arrays in localStorage
*/
export function storedSetSignal<T>(
key: string,
initialValue: Set<T>,
injector: Injector,
debounceMs = 300,
): WritableSignal<Set<T>> {
let parsedValue = initialValue;
// Check cache first
if (setSignalCache.has(key)) {
parsedValue = setSignalCache.get(key) as Set<T>;
} else if (isLocalStorageAvailable()) {
try {
const storedValue = localStorage.getItem(key);
if (storedValue) {
const values = JSON.parse(storedValue) as T[];
parsedValue = new Set(values);
// Cache the parsed Set
setSignalCache.set(key, parsedValue as Set<unknown>);
}
} catch (error) {
console.warn(
`Failed to parse stored Set for key "${key}". Using initial value.`,
error,
);
try {
localStorage.removeItem(key);
} catch {
// Ignore removal errors
}
}
}
const s = signal(parsedValue);
let timeoutId: ReturnType<typeof setTimeout> | null = null;
let isFirstRun = true;
runInInjectionContext(injector, () => {
effect(() => {
const value = s();
// Skip the first execution to avoid writing back what we just read
if (isFirstRun) {
isFirstRun = false;
return;
}
// Clear any pending timeout
if (timeoutId !== null) {
clearTimeout(timeoutId);
}
// Debounce the localStorage write
timeoutId = setTimeout(() => {
if (isLocalStorageAvailable()) {
try {
// Convert Set to array for JSON serialization
const values = Array.from(value);
localStorage.setItem(key, JSON.stringify(values));
// Update cache
setSignalCache.set(key, value as Set<unknown>);
} catch (error) {
console.warn(`Failed to save Set for key "${key}" to localStorage.`, error);
}
}
timeoutId = null;
}, debounceMs);
});
});
return s;
}