Skip to content

Commit

Permalink
feat(promo-manager): implement promoTabSync plugin
Browse files Browse the repository at this point in the history
  • Loading branch information
vanilla-wave committed Jan 29, 2025
1 parent b254184 commit cd00729
Show file tree
Hide file tree
Showing 6 changed files with 294 additions and 19 deletions.
18 changes: 2 additions & 16 deletions src/plugins/multi-tab-sync.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {EventsMap, OnboardingPlugin} from '../types';
import type {Controller} from '../controller';
import {isQuotaExceededError} from '../utils/isQuotaExceededError';

type PluginOptions = {
changeStateLSKey: string;
Expand All @@ -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<any, any, any>;
options: PluginOptions;
Expand Down Expand Up @@ -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;
}
}
Expand Down
7 changes: 4 additions & 3 deletions src/promo-manager/core/controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ export class Controller {
initPromise: Promise<void> | undefined;
saveProgress: () => void;
logger: Logger;
dateNow: () => number;

private status: PromoManagerStatus;

Expand All @@ -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: {
Expand Down Expand Up @@ -162,8 +165,6 @@ export class Controller {
await this.triggerNextPromo();
};

dateNow = () => Date.now();

requestStart = async (slug: Nullable<PromoSlug>) => {
this.logger.debug('Request start preset', slug);
if (!slug) {
Expand Down Expand Up @@ -297,7 +298,7 @@ export class Controller {
}

const info = {
lastCallTime: Date.now(),
lastCallTime: this.dateNow(),
};

this.stateActions.updateProgressInfoByPromo(slug, info);
Expand Down
1 change: 1 addition & 0 deletions src/promo-manager/core/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ export type PromoOptions = {
plugins?: PromoManagerPlugin[];
debugMode?: boolean;
logger?: LoggerOptions;
dateNow?: () => number;
};

export type ConditionContext = {
Expand Down
183 changes: 183 additions & 0 deletions src/promo-manager/plugins/promo-tab-sync-plugin.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
90 changes: 90 additions & 0 deletions src/promo-manager/plugins/promo-tab-sync-plugin.ts
Original file line number Diff line number Diff line change
@@ -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<PluginOptions> = {}) {
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;
}
}
};
}
14 changes: 14 additions & 0 deletions src/utils/isQuotaExceededError.ts
Original file line number Diff line number Diff line change
@@ -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')
);
};

0 comments on commit cd00729

Please sign in to comment.