UNPKG

quodolores

Version:

Monorepo for the Firebase JavaScript SDK

267 lines (235 loc) 8.62 kB
/** * @license * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import { Persistence } from '../../model/public_types'; import { getUA } from '@firebase/util'; import { _isSafari, _isIOS, _isIframe, _isMobileBrowser, _isIE10 } from '../../core/util/browser'; import { PersistenceInternal as InternalPersistence, PersistenceType, PersistenceValue, StorageEventListener } from '../../core/persistence'; import { BrowserPersistenceClass } from './browser'; function _iframeCannotSyncWebStorage(): boolean { const ua = getUA(); return _isSafari(ua) || _isIOS(ua); } // The polling period in case events are not supported export const _POLLING_INTERVAL_MS = 1000; // The IE 10 localStorage cross tab synchronization delay in milliseconds const IE10_LOCAL_STORAGE_SYNC_DELAY = 10; class BrowserLocalPersistence extends BrowserPersistenceClass implements InternalPersistence { static type: 'LOCAL' = 'LOCAL'; constructor() { super(window.localStorage, PersistenceType.LOCAL); this.boundEventHandler = this.onStorageEvent.bind(this); } private readonly boundEventHandler: ( event: StorageEvent, poll?: boolean ) => void; private readonly listeners: Record<string, Set<StorageEventListener>> = {}; private readonly localCache: Record<string, string | null> = {}; // setTimeout return value is platform specific // eslint-disable-next-line @typescript-eslint/no-explicit-any private pollTimer: any | null = null; // Safari or iOS browser and embedded in an iframe. private readonly safariLocalStorageNotSynced = _iframeCannotSyncWebStorage() && _isIframe(); // Whether to use polling instead of depending on window events private readonly fallbackToPolling = _isMobileBrowser(); private forAllChangedKeys( cb: (key: string, oldValue: string | null, newValue: string | null) => void ): void { // Check all keys with listeners on them. for (const key of Object.keys(this.listeners)) { // Get value from localStorage. const newValue = this.storage.getItem(key); const oldValue = this.localCache[key]; // If local map value does not match, trigger listener with storage event. // Differentiate this simulated event from the real storage event. if (newValue !== oldValue) { cb(key, oldValue, newValue); } } } private onStorageEvent(event: StorageEvent, poll: boolean = false): void { // Key would be null in some situations, like when localStorage is cleared if (!event.key) { this.forAllChangedKeys( (key: string, _oldValue: string | null, newValue: string | null) => { this.notifyListeners(key, newValue); } ); return; } const key = event.key; // Check the mechanism how this event was detected. // The first event will dictate the mechanism to be used. if (poll) { // Environment detects storage changes via polling. // Remove storage event listener to prevent possible event duplication. this.detachListener(); } else { // Environment detects storage changes via storage event listener. // Remove polling listener to prevent possible event duplication. this.stopPolling(); } // Safari embedded iframe. Storage event will trigger with the delta // changes but no changes will be applied to the iframe localStorage. if (this.safariLocalStorageNotSynced) { // Get current iframe page value. const storedValue = this.storage.getItem(key); // Value not synchronized, synchronize manually. if (event.newValue !== storedValue) { if (event.newValue !== null) { // Value changed from current value. this.storage.setItem(key, event.newValue); } else { // Current value deleted. this.storage.removeItem(key); } } else if (this.localCache[key] === event.newValue && !poll) { // Already detected and processed, do not trigger listeners again. return; } } const triggerListeners = (): void => { // Keep local map up to date in case storage event is triggered before // poll. const storedValue = this.storage.getItem(key); if (!poll && this.localCache[key] === storedValue) { // Real storage event which has already been detected, do nothing. // This seems to trigger in some IE browsers for some reason. return; } this.notifyListeners(key, storedValue); }; const storedValue = this.storage.getItem(key); if ( _isIE10() && storedValue !== event.newValue && event.newValue !== event.oldValue ) { // IE 10 has this weird bug where a storage event would trigger with the // correct key, oldValue and newValue but localStorage.getItem(key) does // not yield the updated value until a few milliseconds. This ensures // this recovers from that situation. setTimeout(triggerListeners, IE10_LOCAL_STORAGE_SYNC_DELAY); } else { triggerListeners(); } } private notifyListeners(key: string, value: string | null): void { this.localCache[key] = value; const listeners = this.listeners[key]; if (listeners) { for (const listener of Array.from(listeners)) { listener(value ? JSON.parse(value) : value); } } } private startPolling(): void { this.stopPolling(); this.pollTimer = setInterval(() => { this.forAllChangedKeys( (key: string, oldValue: string | null, newValue: string | null) => { this.onStorageEvent( new StorageEvent('storage', { key, oldValue, newValue }), /* poll */ true ); } ); }, _POLLING_INTERVAL_MS); } private stopPolling(): void { if (this.pollTimer) { clearInterval(this.pollTimer); this.pollTimer = null; } } private attachListener(): void { window.addEventListener('storage', this.boundEventHandler); } private detachListener(): void { window.removeEventListener('storage', this.boundEventHandler); } _addListener(key: string, listener: StorageEventListener): void { if (Object.keys(this.listeners).length === 0) { // Whether browser can detect storage event when it had already been pushed to the background. // This may happen in some mobile browsers. A localStorage change in the foreground window // will not be detected in the background window via the storage event. // This was detected in iOS 7.x mobile browsers if (this.fallbackToPolling) { this.startPolling(); } else { this.attachListener(); } } if (!this.listeners[key]) { this.listeners[key] = new Set(); // Populate the cache to avoid spuriously triggering on first poll. this.localCache[key] = this.storage.getItem(key); } this.listeners[key].add(listener); } _removeListener(key: string, listener: StorageEventListener): void { if (this.listeners[key]) { this.listeners[key].delete(listener); if (this.listeners[key].size === 0) { delete this.listeners[key]; } } if (Object.keys(this.listeners).length === 0) { this.detachListener(); this.stopPolling(); } } // Update local cache on base operations: async _set(key: string, value: PersistenceValue): Promise<void> { await super._set(key, value); this.localCache[key] = JSON.stringify(value); } async _get<T extends PersistenceValue>(key: string): Promise<T | null> { const value = await super._get<T>(key); this.localCache[key] = JSON.stringify(value); return value; } async _remove(key: string): Promise<void> { await super._remove(key); delete this.localCache[key]; } } /** * An implementation of {@link Persistence} of type 'LOCAL' using `localStorage` * for the underlying storage. * * @public */ export const browserLocalPersistence: Persistence = BrowserLocalPersistence;