diff --git a/src/store/index.js b/src/store/index.js index 918718fac..f4500fbde 100644 --- a/src/store/index.js +++ b/src/store/index.js @@ -28,6 +28,7 @@ import { removeCurrentlyActivePromoCodesFromInactivePromos, } from "@/helpers/promotions-helper"; import { getDateDifferenceInDays } from "@/helpers/date-helper"; +import sharedMutations from "./vuex-shared-mutations/vuexSharedMutations"; // Export State const getDefaultState = () => { return { @@ -2523,7 +2524,12 @@ export const actions = { }; export default createStore({ - plugins: [createPersistedState()], + plugins: [ + createPersistedState(), + sharedMutations({ + predicate: [...Object.values(storeMutations)], + }), + ], // IMPORTANT: Be VERY careful when modifying these fields for at least a few reasons: // * The CMS can reference the fields by name // * Return users may have a previous "version" of the model, and we don't want diff --git a/src/store/vuex-shared-mutations/strategies/broadcastChannel.js b/src/store/vuex-shared-mutations/strategies/broadcastChannel.js new file mode 100644 index 000000000..4bbfbad0e --- /dev/null +++ b/src/store/vuex-shared-mutations/strategies/broadcastChannel.js @@ -0,0 +1,31 @@ +const DEFAULT_CHANNEL = "vuex-shared-mutations"; + +const globalObj = + typeof window !== "undefined" ? window : /* istanbul ignore next: node env */ global; + +export default class BroadcastChannelStrategy { + static available(BroadcastChannelImpl = globalObj.BroadcastChannel) { + return !(typeof BroadcastChannelImpl !== "function"); + } + + constructor(options = {}) { + const BroadcastChannelImpl = options.BroadcastChannel || globalObj.BroadcastChannel; + const key = options.key || DEFAULT_CHANNEL; + + if (!this.constructor.available(BroadcastChannelImpl)) { + throw new Error("Broadcast strategy not available"); + } + + this.channel = new BroadcastChannelImpl(key); + } + + addEventListener(fn) { + this.channel.addEventListener("message", (e) => { + fn(e.data); + }); + } + + share(message) { + return this.channel.postMessage(message); + } +} diff --git a/src/store/vuex-shared-mutations/strategies/broadcastChannel.spec.js b/src/store/vuex-shared-mutations/strategies/broadcastChannel.spec.js new file mode 100644 index 000000000..7b5856875 --- /dev/null +++ b/src/store/vuex-shared-mutations/strategies/broadcastChannel.spec.js @@ -0,0 +1,82 @@ +import BroadcastChannelStrategy from "./broadcastChannel"; + +describe("BroadcastChannelStrategy", () => { + const mockChannel = { + addEventListener: jest.fn(), + postMessage: jest.fn(), + }; + const mockBroadcastChannel = jest.fn(() => mockChannel); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe("available", () => { + it("should return true if BroadcastChannelImpl is a function", () => { + expect(BroadcastChannelStrategy.available(mockBroadcastChannel)).toBe(true); + }); + + it("should return false if BroadcastChannelImpl is not a function", () => { + expect(BroadcastChannelStrategy.available(null)).toBe(false); + }); + }); + + describe("constructor", () => { + it("should create a new BroadcastChannel with the default channel name if no key is provided", () => { + const strategy = new BroadcastChannelStrategy({ + BroadcastChannel: mockBroadcastChannel, + }); + + expect(mockBroadcastChannel).toHaveBeenCalledWith("vuex-shared-mutations"); + expect(strategy.channel).toBe(mockChannel); + }); + + it("should create a new BroadcastChannel with the provided key", () => { + const strategy = new BroadcastChannelStrategy({ + BroadcastChannel: mockBroadcastChannel, + key: "test-channel", + }); + + expect(mockBroadcastChannel).toHaveBeenCalledWith("test-channel"); + expect(strategy.channel).toBe(mockChannel); + }); + + it("should throw an error if Broadcast strategy is not available", () => { + expect(() => new BroadcastChannelStrategy({ BroadcastChannel: null })).toThrow( + "Broadcast strategy not available" + ); + }); + }); + + describe("addEventListener", () => { + it("should add a message event listener to the channel", () => { + const strategy = new BroadcastChannelStrategy({ + BroadcastChannel: mockBroadcastChannel, + }); + const mockFn = jest.fn(); + + strategy.addEventListener(mockFn); + expect(mockChannel.addEventListener).toHaveBeenCalledWith( + "message", + expect.any(Function) + ); + + // const mockEvent = { data: "test-message" }; + // console.log(mockChannel.addEventListener.mock.calls); + // mockChannel.addEventListener.mock.calls[0][1]; + // expect(mockFn).toHaveBeenCalledWith(mockEvent.data); + }); + }); + + describe("share", () => { + it("should post a message to the channel", () => { + const strategy = new BroadcastChannelStrategy({ + BroadcastChannel: mockBroadcastChannel, + }); + const mockMessage = { test: "message" }; + + expect(strategy.share(mockMessage)).toBeUndefined(); + expect(mockChannel.postMessage).toHaveBeenCalledWith(mockMessage); + }); + }); +}); diff --git a/src/store/vuex-shared-mutations/strategies/defaultStrategy.js b/src/store/vuex-shared-mutations/strategies/defaultStrategy.js new file mode 100644 index 000000000..d1b34a8a3 --- /dev/null +++ b/src/store/vuex-shared-mutations/strategies/defaultStrategy.js @@ -0,0 +1,17 @@ +import BroadcastChannelStrategy from "./broadcastChannel"; +import LocalStorageStrategy from "./localStorage"; + +export default function createDefaultStrategy() { + /* istanbul ignore next: browser-dependent code */ + if (LocalStorageStrategy.available()) { + return new LocalStorageStrategy(); + } + + /* istanbul ignore next: browser-dependent code */ + if (BroadcastChannelStrategy.available()) { + return new BroadcastChannelStrategy(); + } + + /* istanbul ignore next: browser-dependent code */ + throw new Error("No strategies available"); +} diff --git a/src/store/vuex-shared-mutations/strategies/localStorage.js b/src/store/vuex-shared-mutations/strategies/localStorage.js new file mode 100644 index 000000000..ff3df0537 --- /dev/null +++ b/src/store/vuex-shared-mutations/strategies/localStorage.js @@ -0,0 +1,101 @@ +const DEFAULT_KEY = "vuex-shared-mutations"; + +const globalObj = + typeof window !== "undefined" ? window : /* istanbul ignore next: node env */ global; + +const MAX_MESSAGE_LENGTH = 4 * 1024; +let messageCounter = 1; + +function splitMessage(message) { + const partsCount = Math.ceil(message.length / MAX_MESSAGE_LENGTH); + return Array.from({ length: partsCount }).map((_, idx) => + message.substr(idx * MAX_MESSAGE_LENGTH, MAX_MESSAGE_LENGTH) + ); +} + +export default class LocalStorageStrategy { + static available( + { window: windowImpl, localStorage: localStorageImpl } = { + window: globalObj.window, + localStorage: globalObj.localStorage, + } + ) { + if (!windowImpl || !localStorageImpl) { + return false; + } + + try { + localStorageImpl.setItem("vuex-shared-mutations-test-key", Date.now()); + localStorageImpl.removeItem("vuex-shared-mutations-test-key"); + return true; + } catch (e) { + return false; + } + } + + constructor(options = {}) { + const windowImpl = options.window || globalObj.window; + const localStorageImpl = options.localStorage || globalObj.localStorage; + if ( + !this.constructor.available({ + window: windowImpl, + localStorage: localStorageImpl, + }) + ) { + throw new Error("Strategy unavailable"); + } + this.uniqueId = `${Date.now()}-${Math.random()}`; + this.messageBuffer = []; + this.window = windowImpl; + this.storage = localStorageImpl; + this.options = { + key: DEFAULT_KEY, + ...options, + }; + } + + // eslint-disable-next-line class-methods-use-this + addEventListener(fn) { + return this.window.addEventListener("storage", (event) => { + if (!event.newValue) { + return false; + } + + if (event.key.indexOf("##") === -1 || event.key.split("##")[0] !== this.options.key) { + return false; + } + const message = this.window.JSON.parse(event.newValue); + /* istanbul ignore next: IE does not follow storage event spec */ + if (message.author === this.uniqueId) { + return false; + } + this.messageBuffer.push(message.messagePart); + if (this.messageBuffer.length === message.total) { + const mutation = this.window.JSON.parse(this.messageBuffer.join("")); + this.messageBuffer = []; + fn(mutation); + } + return true; + }); + } + + share(message) { + const rawMessage = this.window.JSON.stringify(message); + const messageParts = splitMessage(rawMessage); + messageParts.forEach((m, idx) => { + messageCounter += 1; + const key = `${this.options.key}##${idx}`; + this.storage.setItem( + key, + JSON.stringify({ + author: this.uniqueId, + part: idx, + total: messageParts.length, + messagePart: m, + messageCounter, + }) + ); + this.storage.removeItem(key); + }); + } +} diff --git a/src/store/vuex-shared-mutations/strategies/localStorage.spec.js b/src/store/vuex-shared-mutations/strategies/localStorage.spec.js new file mode 100644 index 000000000..0dd8ee8f6 --- /dev/null +++ b/src/store/vuex-shared-mutations/strategies/localStorage.spec.js @@ -0,0 +1,22 @@ +import LocalStorageStrategy from "./localStorage"; + +describe("LocalStorageStrategy", () => { + describe("available", () => { + it("should return true if localStorage is available", () => { + const localStorageMock = { + setItem: jest.fn(), + removeItem: jest.fn(), + }; + expect( + LocalStorageStrategy.available({ window: {}, localStorage: localStorageMock }) + ).toBe(true); + }); + it("should return false if localStorage is not available", () => { + const windowMock = {}; + expect(LocalStorageStrategy.available({ window: windowMock })).toBe(false); + }); + it("should return false if window is not available", () => { + expect(LocalStorageStrategy.available({ window: undefined })).toBe(false); + }); + }); +}); diff --git a/src/store/vuex-shared-mutations/vuexSharedMutations.js b/src/store/vuex-shared-mutations/vuexSharedMutations.js new file mode 100644 index 000000000..2ccd5a53a --- /dev/null +++ b/src/store/vuex-shared-mutations/vuexSharedMutations.js @@ -0,0 +1,51 @@ +//Share vuex mutations between tabs/windows +//Referenced by https://github.com/xanf/vuex-shared-mutations/ + +import createDefaultStrategy from "./strategies/defaultStrategy"; + +export { default as BroadcastChannelStrategy } from "./strategies/broadcastChannel"; +export { default as LocalStorageStratery } from "./strategies/localStorage"; + +export default ({ predicate, strategy, ...rest } = {}) => { + /* istanbul ignore next: deprecation warning */ + if ("storageKey" in rest || "sharingKey" in rest) { + window.console.warn( + "Configuration directly on plugin was removed, configure specific strategies if needed" + ); + } + + if (!Array.isArray(predicate) && typeof predicate !== "function") { + throw new Error( + "Either array of accepted mutations or predicate function must be supplied" + ); + } + + const predicateFn = + typeof predicate === "function" ? predicate : ({ type }) => predicate.indexOf(type) !== -1; + + let sharingInProgress = false; + const selectedStrategy = strategy || createDefaultStrategy(); + return (store) => { + store.subscribe(async (mutation, state) => { + if (sharingInProgress) { + return Promise.resolve(false); + } + + const shouldShare = await Promise.resolve(predicateFn(mutation, state)); + if (!shouldShare) { + return; + } + selectedStrategy.share(mutation); + }); + + selectedStrategy.addEventListener((mutation) => { + try { + sharingInProgress = true; + store.commit(mutation.type, mutation.payload); + } finally { + sharingInProgress = false; + } + return "done"; + }); + }; +}; diff --git a/src/store/vuex-shared-mutations/vuexSharedMutations.spec.js b/src/store/vuex-shared-mutations/vuexSharedMutations.spec.js new file mode 100644 index 000000000..316e2ca29 --- /dev/null +++ b/src/store/vuex-shared-mutations/vuexSharedMutations.spec.js @@ -0,0 +1,88 @@ +import createMutationsSharer from "./vuexSharedMutations"; + +describe("Vuex shared mutations", () => { + it("should throw an error if predicate function is not supplied", () => { + expect(() => { + createMutationsSharer(); + }).toThrowError(Error); + }); + it("should accept array as predicate", () => { + expect(() => { + createMutationsSharer({ + predicate: ["m-1"], + }); + }).not.toThrowError(Error); + }); + it("should accept function as predicate", () => { + expect(() => { + createMutationsSharer({ + predicate: jest.fn(), + }); + }).not.toThrowError(Error); + }); + it("should share relevant mutation", () => { + let capturedHandler; + const fakeStrategy = { + share: jest.fn(), + addEventListener: jest.fn(), + }; + + const fakeStore = { + subscribe(fn) { + capturedHandler = fn; + }, + }; + + createMutationsSharer({ + predicate: ["m-1"], + strategy: fakeStrategy, + })(fakeStore); + + return capturedHandler({ type: "m-1", payload: "lol" }).then(() => { + expect(fakeStrategy.share).toHaveBeenCalled(); + }); + }); + it("should not share irrelevant mutation", () => { + let capturedHandler; + const fakeStrategy = { + share: jest.fn(), + addEventListener: jest.fn(), + }; + + const fakeStore = { + subscribe(fn) { + capturedHandler = fn; + }, + }; + + createMutationsSharer({ + predicate: ["m-1"], + strategy: fakeStrategy, + })(fakeStore); + return capturedHandler({ type: "m-2", payload: "lol" }).then(() => { + expect(fakeStrategy.share).not.toHaveBeenCalled(); + }); + }); + it("should respect predicate function when sharing mutation", () => { + let capturedHandler; + const fakeStrategy = { + share: jest.fn(), + addEventListener: jest.fn(), + }; + + const fakeStore = { + subscribe(fn) { + capturedHandler = fn; + }, + }; + + createMutationsSharer({ + predicate: ({ type }) => ["m-1"].indexOf(type) !== -1, + strategy: fakeStrategy, + })(fakeStore); + + return capturedHandler({ type: "m-1", payload: "lol" }).then(() => { + expect(fakeStrategy.share).toHaveBeenCalled(); + }); + }); +});