diff --git a/src/plugins/multi-tab-sync.ts b/src/plugins/multi-tab-sync.ts index 558d8a8..d306a49 100644 --- a/src/plugins/multi-tab-sync.ts +++ b/src/plugins/multi-tab-sync.ts @@ -1,5 +1,6 @@ import {EventsMap, OnboardingPlugin} from '../types'; import type {Controller} from '../controller'; +import {isQuotaExceededError} from '../utils/isQuotaExceededError'; type PluginOptions = { changeStateLSKey: string; @@ -15,21 +16,6 @@ const DEFAULT_PLUGIN_OPTIONS = { __unstable_enableStateSync: false, }; export class MultiTabSyncPlugin implements OnboardingPlugin { - static isQuotaExceededError(err: unknown): boolean { - return ( - err instanceof DOMException && - // everything except Firefox - (err.code === 22 || - // Firefox - err.code === 1014 || - // test name field too, because code might not be present - // everything except Firefox - err.name === 'QuotaExceededError' || - // Firefox - err.name === 'NS_ERROR_DOM_QUOTA_REACHED') - ); - } - name = 'multiTabSyncPlugin'; onboardingInstance?: Controller; options: PluginOptions; @@ -85,7 +71,7 @@ export class MultiTabSyncPlugin implements OnboardingPlugin { try { localStorage.setItem(this.options.changeStateLSKey, JSON.stringify(newValue)); } catch (e) { - if (MultiTabSyncPlugin.isQuotaExceededError(e)) { + if (isQuotaExceededError(e)) { this.isQuotaExceeded = true; } } diff --git a/src/promo-manager/core/controller.ts b/src/promo-manager/core/controller.ts index 26bff19..3ed394f 100644 --- a/src/promo-manager/core/controller.ts +++ b/src/promo-manager/core/controller.ts @@ -65,6 +65,7 @@ export class Controller { initPromise: Promise | undefined; saveProgress: () => void; logger: Logger; + dateNow: () => number; private status: PromoManagerStatus; @@ -80,6 +81,8 @@ export class Controller { }); this.logger.debug('Initialization started'); + this.dateNow = options.dateNow ?? Date.now; + this.state = JSON.parse( JSON.stringify({ base: { @@ -162,8 +165,6 @@ export class Controller { await this.triggerNextPromo(); }; - dateNow = () => Date.now(); - requestStart = async (slug: Nullable) => { this.logger.debug('Request start preset', slug); if (!slug) { @@ -297,7 +298,7 @@ export class Controller { } const info = { - lastCallTime: Date.now(), + lastCallTime: this.dateNow(), }; this.stateActions.updateProgressInfoByPromo(slug, info); diff --git a/src/promo-manager/core/types.ts b/src/promo-manager/core/types.ts index 5fee023..10ba253 100644 --- a/src/promo-manager/core/types.ts +++ b/src/promo-manager/core/types.ts @@ -60,6 +60,7 @@ export type PromoOptions = { plugins?: PromoManagerPlugin[]; debugMode?: boolean; logger?: LoggerOptions; + dateNow?: () => number; }; export type ConditionContext = { diff --git a/src/promo-manager/plugins/promo-tab-sync-plugin.test.ts b/src/promo-manager/plugins/promo-tab-sync-plugin.test.ts new file mode 100644 index 0000000..c5a91b7 --- /dev/null +++ b/src/promo-manager/plugins/promo-tab-sync-plugin.test.ts @@ -0,0 +1,183 @@ +import {Controller} from '../core/controller'; +import {testOptions} from '../tests/options'; +import {PromoTabSyncPlugin} from './promo-tab-sync-plugin'; +import {waitForNextTick} from '../tests/utils'; + +beforeEach(() => { + window.localStorage.clear(); +}); + +// Wed Jan 15 2025 20:57:54 GMT+0100 +const DATE_NOW = 1736971074738; +const DATE_IN_FUTURE = DATE_NOW + 1; +const DATE_IN_PAST = DATE_NOW - 1; + +it('finish promo -> save value to LS', async () => { + const controller = new Controller({ + ...testOptions, + plugins: [ + new PromoTabSyncPlugin({ + stateLSKey: 'someKey', + __UNSTABLE__syncState: true, + }), + ], + dateNow: () => DATE_NOW, + }); + + await controller.ensureInit(); + controller.finishPromo('boardPoll'); + + const value = JSON.parse(localStorage.getItem('someKey') ?? ''); + + expect(value).toEqual({ + date: DATE_NOW, + value: { + finishedPromos: ['boardPoll'], + progressInfoByPromo: { + boardPoll: { + lastCallTime: DATE_NOW, + }, + }, + }, + }); +}); + +it('cancel promo -> save value to LS', async () => { + const controller = new Controller({ + ...testOptions, + plugins: [ + new PromoTabSyncPlugin({ + stateLSKey: 'someKey', + __UNSTABLE__syncState: true, + }), + ], + dateNow: () => DATE_NOW, + }); + + await controller.ensureInit(); + controller.cancelPromo('boardPoll'); + + const value = JSON.parse(localStorage.getItem('someKey') ?? ''); + + expect(value).toEqual({ + date: DATE_NOW, + value: { + finishedPromos: [], + progressInfoByPromo: { + boardPoll: { + lastCallTime: DATE_NOW, + }, + }, + }, + }); +}); + +it('tab focus -> apply value from LS', async () => { + const controller = new Controller({ + ...testOptions, + plugins: [ + new PromoTabSyncPlugin({ + stateLSKey: 'someKey', + __UNSTABLE__syncState: true, + }), + ], + dateNow: () => DATE_IN_PAST, + }); + + await controller.ensureInit(); + + const newValue = { + date: DATE_NOW, + value: { + finishedPromos: ['boardPoll'], + progressInfoByPromo: { + boardPoll: { + lastCallTime: DATE_IN_FUTURE, + }, + }, + }, + }; + window.localStorage.setItem('someKey', JSON.stringify(newValue)); + + // current date > event date + controller.dateNow = () => DATE_IN_FUTURE; + document.dispatchEvent(new Event('visibilitychange')); + + await waitForNextTick(); + + expect(controller.state.progress).toEqual(newValue.value); +}); + +it('promo finished in fresh state -> closes promo', async () => { + const controller = new Controller({ + ...testOptions, + plugins: [ + new PromoTabSyncPlugin({ + stateLSKey: 'someKey', + __UNSTABLE__syncState: true, + }), + ], + dateNow: () => DATE_NOW, + }); + + await controller.ensureInit(); + await controller.requestStart('boardPoll'); + + const newValue = { + date: DATE_IN_FUTURE, + value: { + finishedPromos: ['boardPoll'], + progressInfoByPromo: { + boardPoll: { + lastCallTime: DATE_IN_FUTURE, + }, + }, + }, + }; + window.localStorage.setItem('someKey', JSON.stringify(newValue)); + + // current date > event date + controller.dateNow = () => DATE_IN_FUTURE; + document.dispatchEvent(new Event('visibilitychange')); + + await waitForNextTick(); + + expect(controller.state.progress).toEqual(newValue.value); +}); + +it('old value int ls + tab focus -> dont apply change', async () => { + const controller = new Controller({ + ...testOptions, + plugins: [ + new PromoTabSyncPlugin({ + stateLSKey: 'someKey', + __UNSTABLE__syncState: true, + }), + ], + dateNow: () => DATE_NOW, + }); + + await controller.ensureInit(); + + const oldValue = { + date: DATE_IN_PAST, // old data in LS + value: { + finishedPromos: ['boardPoll'], + progressInfoByPromo: { + boardPoll: { + lastCallTime: DATE_NOW, + }, + }, + }, + }; + window.localStorage.setItem('someKey', JSON.stringify(oldValue)); + + // current date > event date + controller.dateNow = () => DATE_IN_FUTURE; + document.dispatchEvent(new Event('visibilitychange')); + + await waitForNextTick(); + + expect(controller.state.progress?.finishedPromos.length).toBe(0); + expect(controller.state.progress?.progressInfoByPromo.boardPoll).toBe(undefined); +}); diff --git a/src/promo-manager/plugins/promo-tab-sync-plugin.ts b/src/promo-manager/plugins/promo-tab-sync-plugin.ts new file mode 100644 index 0000000..a806817 --- /dev/null +++ b/src/promo-manager/plugins/promo-tab-sync-plugin.ts @@ -0,0 +1,90 @@ +import type {Controller} from '../core/controller'; +import {PromoManagerPlugin} from '../core/types'; +import {isQuotaExceededError} from '../../utils/isQuotaExceededError'; + +type PluginOptions = { + __UNSTABLE__syncState: boolean; + stateLSKey: string; +}; + +const DEFAULT_PLUGIN_OPTIONS = { + __UNSTABLE__syncState: false, + stateLSKey: 'promoManager.plugin-sync.state', +}; + +export class PromoTabSyncPlugin implements PromoManagerPlugin { + name = 'promoTabSyncPlugin'; + promoManager?: Controller; + options: PluginOptions; + + isQuotaExceeded = false; + + storeChangedTime: number; + + constructor(userOptions: Partial = {}) { + this.options = { + ...DEFAULT_PLUGIN_OPTIONS, + ...userOptions, + }; + + this.storeChangedTime = Date.now(); + } + + apply: PromoManagerPlugin['apply'] = ({promoManager}) => { + this.promoManager = promoManager; + + this.storeChangedTime = promoManager.dateNow(); + + if (this.options.__UNSTABLE__syncState) { + promoManager.events.subscribe('finishPromo', () => { + this.saveStateToLS({ + date: promoManager.dateNow(), + value: promoManager.state.progress, + }); + + this.storeChangedTime = promoManager.dateNow(); + }); + + promoManager.events.subscribe('cancelPromo', () => { + this.saveStateToLS({ + date: promoManager.dateNow(), + value: promoManager.state.progress, + }); + + this.storeChangedTime = promoManager.dateNow(); + }); + + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'visible') { + const lsValue = JSON.parse( + localStorage.getItem(this.options.stateLSKey) ?? '{}', + ); + + if (lsValue && lsValue.date && lsValue.value) { + const isFreshData = lsValue.date > this.storeChangedTime; + + if (isFreshData) { + promoManager.state.progress = lsValue.value; + promoManager['emitChange'](); + + this.storeChangedTime = lsValue.date; + } + } + } + }); + } + }; + + saveStateToLS = (newValue: any) => { + if (this.isQuotaExceeded) { + return; + } + try { + localStorage.setItem(this.options.stateLSKey, JSON.stringify(newValue)); + } catch (e) { + if (isQuotaExceededError(e)) { + this.isQuotaExceeded = true; + } + } + }; +} diff --git a/src/utils/isQuotaExceededError.ts b/src/utils/isQuotaExceededError.ts new file mode 100644 index 0000000..39263de --- /dev/null +++ b/src/utils/isQuotaExceededError.ts @@ -0,0 +1,14 @@ +export const isQuotaExceededError = (err: unknown): boolean => { + return ( + err instanceof DOMException && + // everything except Firefox + (err.code === 22 || + // Firefox + err.code === 1014 || + // test name field too, because code might not be present + // everything except Firefox + err.name === 'QuotaExceededError' || + // Firefox + err.name === 'NS_ERROR_DOM_QUOTA_REACHED') + ); +};