diff --git a/projects/core/browser/storage/index.test.ts b/projects/core/browser/storage/index.test.ts index 9dc006b..a897147 100644 --- a/projects/core/browser/storage/index.test.ts +++ b/projects/core/browser/storage/index.test.ts @@ -1,6 +1,20 @@ import { Component, signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { storage } from './index'; +import { getWebStorage, LOCAL_STORAGE, SESSION_STORAGE, storage, type StorageLike } from './index'; + +const createMemoryStorage = (initial?: Record): StorageLike => { + const store = new Map(initial ? Object.entries(initial) : undefined); + + return { + getItem: key => store.get(key) ?? null, + setItem: (key, value) => { + store.set(key, value); + }, + removeItem: key => { + store.delete(key); + }, + }; +}; describe(storage.name, () => { let mockLocalStorage: Record; @@ -258,7 +272,7 @@ describe(storage.name, () => { describe('sessionStorage', () => { @Component({ template: '{{ token() }}' }) class TestComponent { - readonly token = storage('token', '', { type: 'session' }); + readonly token = storage('token', '', { storage: 'session' }); } const createComponent = () => { @@ -278,6 +292,38 @@ describe(storage.name, () => { }); }); + describe('deprecated type option', () => { + it('should still select sessionStorage', () => { + @Component({ template: '' }) + class TestComponent { + readonly token = storage('token', '', { type: 'session' }); + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + fixture.componentInstance.token.set('abc123'); + + expect(mockSessionStorage['token']).toBe('abc123'); + expect(mockLocalStorage['token']).toBeUndefined(); + }); + + it('should be ignored when the storage option is provided', () => { + @Component({ template: '' }) + class TestComponent { + readonly token = storage('token', '', { type: 'session', storage: 'local' }); + } + + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + + fixture.componentInstance.token.set('abc123'); + + expect(mockLocalStorage['token']).toBe('abc123'); + expect(mockSessionStorage['token']).toBeUndefined(); + }); + }); + describe('mergeResolver option', () => { @Component({ template: '' }) class TestComponent { @@ -471,4 +517,183 @@ describe(storage.name, () => { expect(mockLocalStorage['custom']).toBe('custom-42'); }); }); + + describe('custom storage via options.storage', () => { + let customStorage: StorageLike; + + beforeEach(() => { + customStorage = createMemoryStorage(); + }); + + @Component({ template: '' }) + class TestComponent { + readonly value = storage('inline', 'initial', { storage: customStorage }); + } + + const createComponent = () => { + const fixture = TestBed.createComponent(TestComponent); + fixture.detectChanges(); + return fixture.componentInstance; + }; + + it('should use the provided storage and not touch Web Storage', () => { + const component = createComponent(); + + component.value.set('custom'); + + expect(component.value()).toBe('custom'); + expect(customStorage.getItem('inline')).toBe('custom'); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + + it('should take precedence over the DI token', () => { + const diStorage = createMemoryStorage(); + TestBed.configureTestingModule({ + providers: [{ provide: LOCAL_STORAGE, useValue: diStorage }], + }); + const component = createComponent(); + + component.value.set('custom'); + + expect(customStorage.getItem('inline')).toBe('custom'); + expect(diStorage.getItem('inline')).toBeNull(); + }); + + it('should behave like a plain signal when storage is null', () => { + @Component({ template: '' }) + class DisabledComponent { + readonly value = storage('disabled', 'initial', { storage: null }); + } + + const fixture = TestBed.createComponent(DisabledComponent); + fixture.detectChanges(); + const component = fixture.componentInstance; + + component.value.set('next'); + + expect(component.value()).toBe('next'); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + }); + + it('should sync instances sharing the same storage', () => { + const writer = createComponent(); + const reader = createComponent(); + + writer.value.set('changed'); + + expect(reader.value()).toBe('changed'); + }); + + it('should isolate instances with distinct storage instances', () => { + const otherStorage = createMemoryStorage(); + + @Component({ template: '' }) + class OtherComponent { + readonly value = storage('inline', 'initial', { storage: otherStorage }); + } + + const writer = createComponent(); + const fixture = TestBed.createComponent(OtherComponent); + fixture.detectChanges(); + const reader = fixture.componentInstance; + + writer.value.set('changed'); + + expect(reader.value()).toBe('initial'); + expect(otherStorage.getItem('inline')).toBe('initial'); + }); + }); + + describe('custom storage via DI tokens', () => { + @Component({ template: '' }) + class LocalComponent { + readonly username = storage('username', 'guest'); + } + + @Component({ template: '' }) + class SessionComponent { + readonly token = storage('token', '', { storage: 'session' }); + } + + const createComponent = (componentType: new () => T) => { + const fixture = TestBed.createComponent(componentType); + fixture.detectChanges(); + return fixture.componentInstance; + }; + + it('should resolve the storage from LOCAL_STORAGE', () => { + const customStorage = createMemoryStorage({ username: 'stored' }); + TestBed.configureTestingModule({ + providers: [{ provide: LOCAL_STORAGE, useValue: customStorage }], + }); + const component = createComponent(LocalComponent); + + expect(component.username()).toBe('stored'); + + component.username.set('alice'); + + expect(customStorage.getItem('username')).toBe('alice'); + expect(window.localStorage.setItem).not.toHaveBeenCalled(); + expect(window.localStorage.getItem).not.toHaveBeenCalled(); + }); + + it('should resolve the storage from SESSION_STORAGE for storage "session"', () => { + const customStorage = createMemoryStorage(); + TestBed.configureTestingModule({ + providers: [{ provide: SESSION_STORAGE, useValue: customStorage }], + }); + const component = createComponent(SessionComponent); + + component.token.set('abc123'); + + expect(customStorage.getItem('token')).toBe('abc123'); + expect(window.sessionStorage.setItem).not.toHaveBeenCalled(); + expect(mockLocalStorage['token']).toBeUndefined(); + }); + + it('should sync sibling instances through the shared DI storage', () => { + TestBed.configureTestingModule({ + providers: [{ provide: LOCAL_STORAGE, useValue: createMemoryStorage() }], + }); + const writer = createComponent(LocalComponent); + const reader = createComponent(LocalComponent); + + writer.username.set('alice'); + + expect(reader.username()).toBe('alice'); + }); + }); + + describe(getWebStorage.name, () => { + it('should return the built-in storage when available', () => { + expect(getWebStorage('local')).toBe(window.localStorage); + expect(getWebStorage('session')).toBe(window.sessionStorage); + }); + + it('should return null when the probe throws', () => { + (window.localStorage.setItem as jest.Mock).mockImplementation(() => { + throw new Error('denied'); + }); + + expect(getWebStorage('local')).toBeNull(); + }); + + it('should return the storage when quota is exceeded but storage is non-empty', () => { + mockLocalStorage['existing'] = 'value'; + (window.localStorage.setItem as jest.Mock).mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError'); + }); + + expect(getWebStorage('local')).toBe(window.localStorage); + }); + + it('should return null when quota is exceeded and storage is empty', () => { + (window.localStorage.setItem as jest.Mock).mockImplementation(() => { + throw new DOMException('quota', 'QuotaExceededError'); + }); + + expect(getWebStorage('local')).toBeNull(); + }); + }); }); diff --git a/projects/core/browser/storage/index.ts b/projects/core/browser/storage/index.ts index 7195bd3..f0bd75e 100644 --- a/projects/core/browser/storage/index.ts +++ b/projects/core/browser/storage/index.ts @@ -1,4 +1,11 @@ -import { type CreateSignalOptions, isSignal, signal, type WritableSignal } from '@angular/core'; +import { + type CreateSignalOptions, + inject, + InjectionToken, + isSignal, + signal, + type WritableSignal, +} from '@angular/core'; import { isPlainObject, setupContext } from '@signality/core/internal'; import { toValue } from '@signality/core/utilities'; import type { MaybeSignal, WithInjector } from '@signality/core/types'; @@ -6,9 +13,45 @@ import { listener, setupSync } from '@signality/core/browser/listener'; import { watcher } from '@signality/core/reactivity/watcher'; import { proxySignal } from '@signality/core/reactivity/proxy-signal'; +/** + * Minimal synchronous storage contract — a structural subset of the + * [Web Storage API](https://developer.mozilla.org/en-US/docs/Web/API/Web_Storage_API). + * + * Any object implementing these three methods can back the {@link storage} utility: + * the built-in `localStorage`/`sessionStorage`, an in-memory store, a cookie-based + * storage, an encrypting wrapper, etc. + */ +export interface StorageLike { + getItem(key: string): string | null; + setItem(key: string, value: string): void; + removeItem(key: string): void; +} + export interface StorageOptions extends CreateSignalOptions, WithInjector { + /** + * Where to persist the value. + * + * - `'local' | 'session'` — the app-wide backend provided by the {@link LOCAL_STORAGE} + * or {@link SESSION_STORAGE} DI token + * - {@link StorageLike} — a custom storage for this call + * - `null` — persistence disabled, the signal behaves like a plain `signal(initialValue)` + * + * @default 'local' + * + * @example + * ```typescript + * const draft = storage('draft', '', { storage: 'session' }); + * const consent = storage('consent', false, { storage: cookieStorage }); + * ``` + */ + readonly storage?: 'local' | 'session' | StorageLike | null; + /** * Storage type to use. + * + * @deprecated Use `storage: 'local' | 'session'` instead. Ignored when the `storage` + * option is provided. Will be removed before 1.0. + * * @default 'local' */ readonly type?: 'local' | 'session'; @@ -89,7 +132,7 @@ interface StorageEventLike { readonly key: string | null; readonly oldValue: string | null; readonly newValue: string | null; - readonly storageArea: Storage | null; + readonly storageArea: StorageLike | null; } /** @@ -121,8 +164,20 @@ interface StorageEventLike { * With options: * ```typescript * const preferences = storage('prefs', defaultPrefs, { - * type: 'session', - * mergeWithInitial: true, + * storage: 'session', + * }); + * ``` + * + * @example + * With a custom storage — per call via `options.storage`, or app-wide via the + * {@link LOCAL_STORAGE}/{@link SESSION_STORAGE} DI tokens: + * ```typescript + * // Per call + * const consent = storage('consent', false, { storage: cookieStorage }); + * + * // App-wide: any object implementing getItem/setItem/removeItem + * TestBed.configureTestingModule({ + * providers: [{ provide: LOCAL_STORAGE, useValue: inMemoryStorage }], * }); * ``` */ @@ -134,13 +189,24 @@ export function storage( const { runInContext } = setupContext(options?.injector, storage); return runInContext(({ isServer }) => { - const type = options?.type ?? 'local'; + if (isServer) { + return signal(initialValue, options); + } - if (isServer || !storageAvailable(type)) { + const configured = options?.storage !== undefined ? options.storage : options?.type ?? 'local'; + const targetStorage = + typeof configured === 'string' + ? inject(configured === 'local' ? LOCAL_STORAGE : SESSION_STORAGE) + : configured; + + if (!targetStorage) { return signal(initialValue, options); } - const targetStorage = type === 'local' ? window.localStorage : window.sessionStorage; + if (ngDevMode) { + assertStorageLike(targetStorage, 'storage'); + } + const serializer = resolveSerializer(initialValue, options); const processValue = (storedValue: T) => { @@ -340,7 +406,16 @@ function inferSerializerType(value: T): keyof typeof Serializers { } } -function storageAvailable(type: 'local' | 'session'): boolean { +/** + * Returns the built-in Web Storage area for the given type, or `null` when it is + * unavailable (server-side rendering, disabled cookies, Safari private mode). + * Performs a write/remove probe before handing the storage out. + */ +export function getWebStorage(type: 'local' | 'session'): StorageLike | null { + if (typeof window === 'undefined') { + return null; + } + let storage: Storage | undefined; try { @@ -348,13 +423,74 @@ function storageAvailable(type: 'local' | 'session'): boolean { const testKey = '__storage_test__'; storage.setItem(testKey, testKey); storage.removeItem(testKey); - return true; + return storage; } catch (e) { - return ( + if ( e instanceof DOMException && e.name === 'QuotaExceededError' && storage !== undefined && storage.length !== 0 + ) { + return storage; + } + + return null; + } +} + +/** + * DI token providing the {@link StorageLike} backend used by {@link storage} when its + * `storage` option is `'local'` (the default). Defaults to the built-in `window.localStorage` + * (see {@link getWebStorage}). + * + * @example + * ```typescript + * // Testing: any object implementing getItem/setItem/removeItem — no window mocks + * TestBed.configureTestingModule({ + * providers: [{ provide: LOCAL_STORAGE, useValue: inMemoryStorage }], + * }); + * + * // Encryption at rest: decorate the built-in storage + * bootstrapApplication(App, { + * providers: [ + * { + * provide: LOCAL_STORAGE, + * useFactory: () => { + * const base = getWebStorage('local'); + * return base && encryptedStorage(base, SECRET); + * }, + * }, + * ], + * }); + * ``` + */ +export const LOCAL_STORAGE = new InjectionToken( + ngDevMode ? 'LOCAL_STORAGE' : '', + { providedIn: 'root', factory: () => getWebStorage('local') } +); + +/** + * DI token providing the {@link StorageLike} backend used by {@link storage} when its + * `storage` option is `'session'`. Defaults to the built-in `window.sessionStorage` + * (see {@link getWebStorage}). See {@link LOCAL_STORAGE} for override examples. + */ +export const SESSION_STORAGE = new InjectionToken( + ngDevMode ? 'SESSION_STORAGE' : '', + { providedIn: 'root', factory: () => getWebStorage('session') } +); + +function assertStorageLike(value: unknown, source: string): asserts value is StorageLike { + const target = value as StorageLike | null; + + if ( + !target || + typeof target.getItem !== 'function' || + typeof target.setItem !== 'function' || + typeof target.removeItem !== 'function' + ) { + throw new Error( + `[${source}] Expected a StorageLike implementation with getItem/setItem/removeItem, ` + + `but received: ${value === null ? 'null' : typeof value}.` ); } }