-
Notifications
You must be signed in to change notification settings - Fork 283
Description
To allow UI updates to be propagated to other browser tabs, we currently use our custom IndexedDB Vuex plugin:
studio/contentcuration/contentcuration/frontend/shared/vuex/indexedDBPlugin/index.js
Lines 1 to 129 in 412f26f
| import { EventEmitter } from 'events'; | |
| import { CHANGE_TYPES } from 'shared/data'; | |
| import { CLIENTID } from 'shared/data/db'; | |
| function getEventName(table, type) { | |
| return `${table}/${type}`; | |
| } | |
| export class Listener { | |
| /** | |
| * @param {Function} callback | |
| */ | |
| constructor(callback) { | |
| this.callback = callback; | |
| this.tableName = null; | |
| this.changeType = null; | |
| this.namespacePrefix = null; | |
| } | |
| /** | |
| * @return {string|null} | |
| */ | |
| getEventName() { | |
| if (!this.tableName || !this.changeType) { | |
| return null; | |
| } | |
| return getEventName(this.tableName, this.changeType); | |
| } | |
| /** | |
| * @param {string} name | |
| * @return {string} | |
| */ | |
| prefix(name) { | |
| return this.namespacePrefix ? `${this.namespacePrefix}/${name}` : name; | |
| } | |
| /** | |
| * @param {String} tableName | |
| * @param {String|Number} changeType | |
| * @param {String|null} [namespacePrefix] | |
| * @return {Listener} | |
| */ | |
| bind(tableName, changeType, namespacePrefix = null) { | |
| changeType = Number(changeType); | |
| if (!Object.values(CHANGE_TYPES).includes(changeType)) { | |
| throw RangeError( | |
| `Change must be ${CHANGE_TYPES.CREATED}, ${CHANGE_TYPES.UPDATED}, or ${CHANGE_TYPES.DELETED}` | |
| ); | |
| } | |
| const listener = new this.constructor(this.callback); | |
| listener.tableName = tableName; | |
| listener.changeType = changeType; | |
| listener.namespacePrefix = namespacePrefix; | |
| return listener; | |
| } | |
| /** | |
| * @param {EventEmitter} events | |
| * @param {Store} store | |
| */ | |
| register(events, store) { | |
| const eventName = this.getEventName(); | |
| if (!eventName) { | |
| console.warn('Cannot register unbound listener: ' + this.callback.toString()); | |
| return; | |
| } | |
| events.addListener(eventName, obj => { | |
| this.callback(store, obj); | |
| }); | |
| } | |
| } | |
| /** | |
| * Returns an IndexedDB listener that triggers a Vuex mutation | |
| * | |
| * @param {String} mutationName | |
| * @return {Listener} | |
| */ | |
| export function commitListener(mutationName) { | |
| return new Listener(function(store, obj) { | |
| store.commit(this.prefix(mutationName), obj); | |
| }); | |
| } | |
| /** | |
| * Returns an IndexedDB listener that triggers a Vuex action | |
| * | |
| * @param {String} actionName | |
| * @return {Listener} | |
| */ | |
| export function dispatchListener(actionName) { | |
| return new Listener(function(store, obj) { | |
| store.dispatch(this.prefix(actionName), obj); | |
| }); | |
| } | |
| /** | |
| * @param {Dexie} db | |
| * @param {Listener[]} listeners | |
| * @return {function(*=): void} | |
| * @constructor | |
| */ | |
| export default function IndexedDBPlugin(db, listeners = []) { | |
| const events = new EventEmitter(); | |
| events.setMaxListeners(1000); | |
| db.on('changes', function(changes) { | |
| changes.forEach(function(change) { | |
| // Don't invoke listeners if their client originated the change | |
| if (CLIENTID !== change.source) { | |
| // Always invoke the listeners with the full object representation | |
| // It is up to the callbacks to know how to parse this. | |
| const obj = Object.assign( | |
| { [db[change.table].schema.primKey.keyPath]: change.key }, | |
| change.obj || {} | |
| ); | |
| events.emit(getEventName(change.table, change.type), obj); | |
| } | |
| }); | |
| }); | |
| return function(store) { | |
| listeners.forEach(listener => listener.register(events, store)); | |
| }; | |
| } |
Together with related logic in the Vuex store factory:
| import Vue from 'vue'; | |
| import Vuex, { Store } from 'vuex'; | |
| import session from './session'; | |
| import ConnectionPlugin from './connectionPlugin'; | |
| import snackbar from './snackbar'; | |
| import errors from './errors'; | |
| import contextMenu from './contextMenu'; | |
| import channel from './channel'; | |
| import file from './file'; | |
| import policies from './policies'; | |
| import SyncProgressPlugin from './syncProgressPlugin'; | |
| import PoliciesPlugin from './policies/plugin'; | |
| import db from 'shared/data/db'; | |
| import IndexedDBPlugin, { Listener, commitListener } from 'shared/vuex/indexedDBPlugin'; | |
| Vue.use(Vuex); | |
| /** | |
| * @param {String} moduleName | |
| * @param {Object<String|Listener>} listeners | |
| * @param {Boolean} namespaced | |
| * @return {Listener[]} | |
| */ | |
| function parseListeners(moduleName, listeners, namespaced = false) { | |
| const parsedListeners = []; | |
| for (let [tableName, tableListeners] of Object.entries(listeners)) { | |
| for (let [changeType, listener] of Object.entries(tableListeners)) { | |
| if (!(listener instanceof Listener)) { | |
| listener = commitListener(listener); | |
| } | |
| parsedListeners.push(listener.bind(tableName, changeType, namespaced ? moduleName : null)); | |
| } | |
| } | |
| return parsedListeners; | |
| } | |
| export default function storeFactory({ | |
| state = {}, | |
| actions = {}, | |
| getters = {}, | |
| mutations = {}, | |
| modules = {}, | |
| plugins = [], | |
| listeners = {}, | |
| } = {}) { | |
| modules = { | |
| session, | |
| errors, | |
| snackbar, | |
| contextMenu, | |
| channel, | |
| file, | |
| policies, | |
| ...modules, | |
| }; | |
| const parsedListeners = parseListeners(null, listeners); | |
| for (let [moduleName, module] of Object.entries(modules)) { | |
| if (module.listeners) { | |
| parsedListeners.push(...parseListeners(moduleName, module.listeners, module.namespaced)); | |
| delete module.listeners; | |
| } | |
| } | |
| return new Store({ | |
| state, | |
| actions, | |
| getters, | |
| mutations, | |
| plugins: [ | |
| ConnectionPlugin, | |
| SyncProgressPlugin, | |
| IndexedDBPlugin(db, parsedListeners), | |
| PoliciesPlugin, | |
| ...plugins, | |
| ], | |
| modules, | |
| }); | |
| } |
this plugin ensures that whenever data are updated in an IndexedDB table from one browser tab, they will be propagated to other browser tabs by calling a related Vuex mutation that will update Vuex state which will then reactively propagate to components. Vuex mutations are registered in Vuex modules in listeners property. For example:
studio/contentcuration/contentcuration/frontend/channelEdit/vuex/contentNode/index.js
Lines 64 to 69 in 412f26f
| listeners: { | |
| [TABLE_NAMES.CONTENTNODE]: { | |
| [CHANGE_TYPES.CREATED]: 'ADD_CONTENTNODE', | |
| [CHANGE_TYPES.UPDATED]: 'ADD_CONTENTNODE', | |
| [CHANGE_TYPES.DELETED]: 'REMOVE_CONTENTNODE', | |
| }, |
says that whenever an item of the IndexedDB content node table is created/updated/deleted then ADD_CONTENTNODE/REMOVE_CONTENTNODE mutations will be committed.
These mutations will be commited in all browser tabs except the tab that caused the IndexedDB table update:
studio/contentcuration/contentcuration/frontend/shared/vuex/indexedDBPlugin/index.js
Lines 113 to 114 in 412f26f
| // Don't invoke listeners if their client originated the change | |
| if (CLIENTID !== change.source) { |
As we're moving away from using global Vuex state towards more local states (e.g. in-component state, composable state), we need to have this logic available for use outside of the Vuex context.
For example, to be able to resolve #3447 while maintaining browser multi-tab synchronization, we'll need to listen to IndexedDB content node table updates and propagate them to a new state that will no more live in Vuex.
The goal of this issue is to prepare this IndexedDB listeners logic so that it can be used flexibly from components data, composable functions, etc.
Background
- We accumulate content nodes data in Vuex
contentNode/statefrom various Studio features of thechannelEditapp and there is no mechanism for clearing them which causes memory leaks. This issue is part of a larger group of issues (see Improve Studio's performance when navigating and editing channels' content (channelEditapp) #3363) that aim to refactor problematic features away from using Vuex global state towards private in-components state or state that’s shared between more components but is cleared at some point and optimized performance-wise in general. - Ultimately, we want to get rid of globally stored content nodes data completely, however, this will be implemented incrementally, and therefore it’s fine to use mixed sources of data in the transition stage as long as all features continue working well from the user-point of view. Using composables is not required in all cases but is recommended as it has proven to be useful for state management across our products and it is flexible enough to allow us to keep using Vuex partially during the transition stage.
Acceptance criteria
- A logic similar to the IndexedDB Vuex plugin can be used from components and composables and it has no dependencies on Vuex
- Similarly to how our current Vuex IndexedDB plugin works, the new logic shouldn't invoke a listener handler in a tab that originated an IndexedDB table update
- Our current IndexedDB plugin stays functional
- Before merging, we are certain that the new implementation can be successfully used from at least one issue from the list of issues above that are blocked by this issue
Notes
- Because of the last acceptance criteria, it may make sense to work on this issue together with one simpler issues from "Stop using Vuex global content nodes state" group, for example Stop using Vuex global content nodes state - “Trash” feature #3447. We can then fulfill this acceptance criterion by ensuring that updates of a newly implemented local state are propagated to all browser tabs.
- You can see Frontend data handling and Data flow diagram for high-level overview of frontend data flow
Blocking
- Stop using Vuex global content nodes state - “Import from other channels” feature #3437
- Stop using Vuex global content nodes state - “Clipboard” feature #3446
- Stop using Vuex global content nodes state - “Trash” feature #3447
- Stop using Vuex global content nodes state - “Edit modal” feature #3451
- Stop using Vuex global content nodes state - “Side tree navigation” #3452
- Stop using Vuex global content nodes state - “Main tree navigation” #3453
- Stop using Vuex global content nodes state - “Move modal” #3458
- Stop using Vuex global content nodes state - “Staging tree navigation” #3459
- Stop using Vuex global content nodes state - "Add previous/next steps" feature #3462
- Stop using Vuex global content nodes state - "ResourcePanel" #3463
- Stop using Vuex file module #3466