UNPKG

react-synced-object

Version:

A lightweight, efficient, and versatile package for seamless state synchronization across a React application.

634 lines (610 loc) 26.9 kB
export class SyncedObjectManager { // Vars: static localStorage = window.localStorage; static syncedObjects = new Map(); static pendingSyncTasks = new Map(); static componentCounter = 0; static globalSafeMode = (process.env.NODE_ENV === "production") ? false : true; // Main Interface: /** * Initialize a new {@link SyncedObject} using provided options. * @param {string} key The synced object's identifier. * @param {"temp"|"local"|"custom"} type The type of synced object, affecting sync behavior. * @param {Object} [options] - The options for initializing the synced object. * @param {Object} [options.defaultValue={}] * @param {number} [options.debounceTime=0] * @param {"prevent"|"allow"|"finish"} [options.reloadBehavior="prevent"] * @param {Object} [options.customSyncFunctions] * @param {Object} [options.callbackFunctions] * @param {boolean} [options.safeMode=true | false] * @returns {SyncedObject} The newly created synced object. * @example * const myObject = initializeSyncedObject("myObject", "local"}; */ static initializeSyncedObject(key, type, options) { // Check for duplicates: if (SyncedObjectManager.syncedObjects.has(key)) { return SyncedObjectManager.syncedObjects.get(key); } // Create synced object: const { defaultValue = {}, debounceTime = 0, reloadBehavior = "prevent", customSyncFunctions, callbackFunctions, safeMode = SyncedObjectManager.globalSafeMode } = options || {}; const syncedObjectData = { key: key, type: type, data: defaultValue, changelog: [], debounceTime: debounceTime, reloadBehavior: reloadBehavior, safeMode: safeMode, callerId: null, resolvePromise: null, pull: customSyncFunctions?.pull, push: customSyncFunctions?.push, onSuccess: callbackFunctions?.onSuccess, onError: callbackFunctions?.onError }; SyncedObjectManager.validateInput("initialization", syncedObjectData); const syncedObject = new SyncedObject(syncedObjectData); // Add to storage: SyncedObjectManager.syncedObjects.set(key, syncedObject); // Initial sync: SyncedObjectManager.queueSyncTask(syncedObject, 0, "pull"); // Return: return syncedObject; } /** * Find the {@link SyncedObject} with the provided key, if it exists. * @param {string} key Requested object key. * @returns {SyncedObject|undefined} The requested synced object, or undefined if nonexistent. * @example * const myObject = getSyncedObject("myObject"); // Returns undefined if 'myObject' does not exist. */ static getSyncedObject(key) { return SyncedObjectManager.syncedObjects.get(key); } /** * Delete the {@link SyncedObject} with the provided key from the object manager, if it exists. * @param {string} key Requested object key. * @returns {boolean} Whether deletion was successful. * @example * deleteSyncedObject("myObject"); // myObject.modify() will now throw an error. */ static deleteSyncedObject(key) { const object = SyncedObjectManager.getSyncedObject(key); if (!object) { return false; } if (object.safeMode) { object.modify = function () { throw new SyncedObjectError(`Synced Object Modification: object with key '${key}' has been deleted.`, key, "deleteSyncedObject"); } } else { object.modify = function () { return; } } setTimeout(() => { SyncedObjectManager.updateComponents(object, { requestType: "delete", success: null, error: null }); }, 0); SyncedObjectManager.syncedObjects.delete(key); return true; } /** * Update the {@link SyncedObject} data with the provided key, attempt sync, then return. * - Waits for any pending sync requests to finish before updating. * - Provides the resulting state after synchronization. * @param {string} key Requested object key. * @param {Object|value} updater Overwrites the specified properties of `SyncedObject.data` with the provided values, or the entire field itself. * @returns {Promise<SyncedObject>} The updated synced object. * @throws {SyncedObjectError} If the object does not exist. * @example * const myObject = await modifySyncedObject("myObject", { prop1: "new value", prop2: "new value2" }); * console.log(myObject.data.prop1); // "new value" * console.log(myObject.state.success); // true */ static async updateSyncedObject(key, updater) { // Find synced object: const object = SyncedObjectManager.getSyncedObject(key); if (!object) { throw new SyncedObjectError(`Synced Object Modification: object with key '${key}' does not exist.`, key, "updateSyncedObject"); } // Wait for pending syncs: if (object.resolvePromise) { object.resolvePromise(); } if (SyncedObjectManager.pendingSyncTasks.has(key)) { await new Promise(resolve => { object.resolvePromise = resolve; });; } // Modify object: if (typeof updater === 'object') { for (const [property, value] of Object.entries(updater)) { object.data[property] = value; if (!object.changelog.includes(property)) { object.changelog.push(property); } } } else { object.data = updater; } // Sync object: await SyncedObjectManager.handleModifications(object, 0); return object; } // Local Storage Interface: /** * Find the key or matching keys in local storage. * @param {string|RegExp} keyPattern The pattern to match against keys in local storage. * @param {"data"|"key"} [returnType="data"] Whether to return the data (default) or key matched. * @returns {Array<string>|Array<Object>} An array of matching keys or data objects. * @example * const keys = findInLocalStorage(/myObject/, "key"); * console.log(keys); // ['myObject1', 'myObject2'] */ static findInLocalStorage(keyPattern, returnType = "data") { // Validate input: if (returnType !== "data" && returnType !== "key") { throw new SyncedObjectError(`Failed to find in local storage: returnType must be 'data' or 'key', found: '${returnType}'.`, keyPattern, "findInLocalStorage"); } // Find keys: const keys = Object.keys(SyncedObjectManager.localStorage); let matchingKeys = []; if (typeof keyPattern === "string" && keyPattern.length > 0) { if (keys.includes(keyPattern)) { matchingKeys = [keyPattern]; } } else if (keyPattern instanceof RegExp) { matchingKeys = keys.filter((key) => keyPattern.test(key)); } else { throw new SyncedObjectError(`Failed to find in local storage: keyPattern must be a non-empty string or a RegExp, found: '${keyPattern}'.`, keyPattern, "findInLocalStorage"); } // Return data or keys: if (returnType === "key") { return matchingKeys; } return matchingKeys.map((key) => { const object = JSON.parse(SyncedObjectManager.localStorage.getItem(key)); return object; }); } /** * Delete some keys from local storage. * @param {string|RegExp} keyPattern The pattern to match against keys in local storage. * @param {"ignore"|"decouple"|"delete"} [affectedObjects="ignore"] Whether to decouple, delete, or ignore any affected synced objects. * - `ignore`: Affected synced objects may re-push their data to local storage again. * - `decouple`: Affected synced objects will be decoupled from local storage, turning into 'temp' objects. * - `delete`: Affected synced objects will be {@link deleteSyncedObject deleted} from the manager. * @returns {Array<string>} An array of deleted keys. * @example * const deletedKeys = removeFromLocalStorage("myObject1", "decouple"); * console.log(deletedKeys); // ['myObject1'] * console.log(getSyncedObject('myObject1')).type; // 'temp' */ static removeFromLocalStorage(keyPattern, affectedObjects = "ignore") { // Validate input: if (affectedObjects !== "decouple" && affectedObjects !== "delete" && affectedObjects !== "ignore") { throw new SyncedObjectError(`Failed to remove from local storage: affectedObjects must be 'decouple', 'delete', 'ignore', found: '${affectedObjects}'.`, keyPattern, "removeFromLocalStorage"); } // Delete keys: const matchingKeys = SyncedObjectManager.findInLocalStorage(keyPattern, "key"); matchingKeys.map((key) => SyncedObjectManager.localStorage.removeItem(key)); // Handle affected objects: if (affectedObjects === "ignore") { return matchingKeys; } if (affectedObjects === "decouple") { matchingKeys.map((key) => { const object = SyncedObjectManager.syncedObjects.get(key); if (object) { object.type = "temp"; } }); return matchingKeys; } if (affectedObjects === "delete") { matchingKeys.map((key) => { SyncedObjectManager.deleteSyncedObject(key); }); return matchingKeys; } } // Backend Utils: static async handleModifications(syncedObject, arg1, arg2) { // Handle modifications on the synced object. let property, debounceTime; if (typeof arg1 === "string") { property = arg1; debounceTime = (arg2 !== undefined) ? arg2 : syncedObject.debounceTime; } else { property = null; debounceTime = (arg1 !== undefined) ? arg1 : syncedObject.debounceTime; } this.validateInput("modification", { syncedObject, property, debounceTime }); // Modify changelogs if needed: if (property && !syncedObject.changelog.includes(property)) { syncedObject.changelog.push(property); } // Rerender dependent components: setTimeout(() => { this.updateComponents(syncedObject, { requestType: "modify", success: null, error: syncedObject.state.error || null }); }, 0); // Handle syncing: await this.queueSyncTask(syncedObject, debounceTime); } static async queueSyncTask(syncedObject, debounceTime, requestType = "push") { // Queue an object to be pushed, debouncing multiple requests. if (syncedObject.type === "temp") { this.attemptSync(syncedObject, requestType); return; } clearTimeout(this.pendingSyncTasks.get(syncedObject.key)); if (debounceTime === 0) { this.pendingSyncTasks.set(syncedObject.key, -1); await this.attemptSync(syncedObject, requestType); this.pendingSyncTasks.delete(syncedObject.key); if (syncedObject.resolvePromise) { syncedObject.resolvePromise(); syncedObject.resolvePromise = null; } return; } const timeoutId = setTimeout(async () => { await this.attemptSync(syncedObject, requestType); this.pendingSyncTasks.delete(syncedObject.key); if (syncedObject.resolvePromise) { syncedObject.resolvePromise(); syncedObject.resolvePromise = null; } }, debounceTime); this.pendingSyncTasks.set(syncedObject.key, timeoutId); } static async attemptSync(syncedObject, requestType) { // Sync an object immediately. let success = true, error = null; try { if (syncedObject.type === "local") { if (requestType === "push") { await this.pushToLocal(syncedObject); } if (requestType === "pull") { await this.pullFromLocal(syncedObject); } } if (syncedObject.type === "custom") { if (requestType === "push") { await this.pushToCustom(syncedObject); } if (requestType === "pull") { await this.pullFromCustom(syncedObject); } } } catch (err) { success = false; error = err; } if (requestType === "delete") { success = false; } this.handleCallBacks(syncedObject, { requestType, success, error }); } static async pullFromLocal(syncedObject) { // Pull data from local storage. const json = this.localStorage.getItem(syncedObject.key); if (json) { syncedObject.data = JSON.parse(json); } else { await this.pushToLocal(syncedObject); } } static async pushToLocal(syncedObject) { // Push data to local storage. this.localStorage.setItem(syncedObject.key, JSON.stringify(syncedObject.data)); } static async pullFromCustom(syncedObject) { // Call the custom pull method to obtain data. if (!syncedObject.pull) { return; } const response = await syncedObject.pull(syncedObject); if (response) { syncedObject.data = response; } else { await this.pushToCustom(syncedObject); } } static async pushToCustom(syncedObject) { // Call the custom push method to send data. if (!syncedObject.push) { return; } const response = await syncedObject.push(syncedObject); } static async handleCallBacks(syncedObject, status) { // Handle callbacks, emit events, and reset changelogs. const { success, error } = status; this.updateComponents(syncedObject, status); if (success) syncedObject.changelog = []; syncedObject.callerId = null; if (syncedObject.type === "temp") { return; } if (success && syncedObject.onSuccess) { syncedObject.onSuccess(syncedObject, status); } if (error && syncedObject.onError) { syncedObject.onError(syncedObject, status); } } // Backend Sub-Utils and Setup: static validateInput(name, data) { // Validate input for interface methods. if (name === "initialization") { if (data.safeMode === false) { return; } const { key, type, debounceTime, reloadBehavior, pull, push, onSuccess, onError } = data; // Warnings: if (type === "custom") { if (!pull && !push) { console.warn(`Synced object initialization with key '${key}': customSyncFunctions not provided for 'custom' object. Use 'temp' or 'local' type instead.`); } if (reloadBehavior === "finish") { console.warn(`Synced object initialization with key '${key}': reloadBehavior 'finish' might not behave as expected for asynchronous functions.`); } } else if (pull || push) { console.warn(`Synced object initialization with key '${key}': customSyncFunctions will not be run for 'temp' or 'local' objects. Use 'custom' type instead.`); } // Errors: const errors = []; if (!key || !type) { errors.push("missing parameters 'key' or 'type'"); } else { if (!typeof key === "string" || key.length <= 0) { errors.push("parameter 'key' must be a non-empty string"); } if (type !== "temp" && type !== "local" && type !== "custom") { errors.push("parameter 'type' must be either 'temp', 'local', or 'custom'"); } } if (debounceTime && (typeof debounceTime !== "number" || debounceTime < 0)) { errors.push("parameter 'debounceTime' must be a non-negative number"); } if (reloadBehavior && (reloadBehavior !== "prevent" && reloadBehavior !== "allow" && reloadBehavior !== "finish")) { errors.push("parameter 'reloadBehavior' must be either 'prevent', 'allow', or 'finish'"); } if (pull && (!typeof pull === "function")) { errors.push("parameter 'customSyncFunctions.pull' must be a function"); } if (push && (!typeof push === "function")) { errors.push("parameter 'customSyncFunctions.push' must be a function"); } if (onSuccess && (!typeof onSuccess === "function")) { errors.push("parameter 'callbackFunctions.onSuccess' must be a function"); } if (onError && (!typeof onError === "function")) { errors.push("parameter 'callbackFunctions.onError' must be a function"); } if (errors.length > 0) { throw new SyncedObjectError(`Failed to initialize synced object:\n[${errors.join('; \n')}]`, key, "initializeSyncedObject"); } } if (name === "modification") { if (data.syncedObject.safeMode === false) { return; } // Errors & Warnings: const { property, debounceTime } = data; const syncedObject = data.syncedObject; const key = syncedObject.key; const errors = []; if (property) { if (typeof property !== "string" || property.length <= 0) { errors.push("parameter 'property' must be a non-empty string"); } else { if (!syncedObject.data.hasOwnProperty(property)) { errors.push(`parameter 'property' must be a property of synced object with key '${key}'`); } } } if (debounceTime && (typeof debounceTime !== "number" || debounceTime < 0)) { errors.push("parameter 'debounceTime' must be a non-negative number"); } if (errors.length > 0) { throw new SyncedObjectError(`Failed to modify due to invalid params:\n[${errors.join('; \n')}]`, key, "modify"); } } } static generateComponentId() { this.componentCounter++; return this.componentCounter; } static updateComponents(syncedObject, status) { // Update syncedObject state: syncedObject.state.success = status.success; syncedObject.state.error = status.error; // Rerender components with hook 'useSyncedObject': const event = new CustomEvent("syncedObjectEvent", { detail: { key: syncedObject.key, changelog: syncedObject.changelog, requestType: status.requestType, callerId: syncedObject.callerId } }); document.dispatchEvent(event); } static initReloadPrevention() { // Prevent reloads on page close. window.addEventListener("beforeunload", (event) => { // Check for pending syncs: for (const [key, timeoutId] of SyncedObjectManager.pendingSyncTasks) { // Object is still syncing: const syncedObject = SyncedObjectManager.getSyncedObject(key); const reloadBehavior = syncedObject.reloadBehavior; if (reloadBehavior === "allow") { continue; } if (reloadBehavior === "finish") { SyncedObjectManager.pendingSyncTasks.delete(syncedObject.key); SyncedObjectManager.attemptSync(syncedObject, "push"); continue; } if (reloadBehavior === "prevent") { event.preventDefault(); event.returnValue = "You have unsaved changes!"; break; } } }); } } SyncedObjectManager.initReloadPrevention(); export class SyncedObjectError extends Error { constructor(message, syncedObjectKey, functionName) { super("SyncedObjectError: \n" + message + " \nSynced Object Key: '" + syncedObjectKey + "' \nFunction Name: " + functionName + " \n"); this.name = "SyncedObjectError"; this.syncedObjectKey = syncedObjectKey; this.functionName = functionName; } } /** * Represents a Synced Object. * @classdesc A Synced Object is used to manage synchronized state and behavior. * - Note: Do not construct this class directly - use factory function initializeSyncedObject() instead. */ export class SyncedObject { constructor(initObject) { const { key, type, data, changelog, debounceTime, reloadBehavior, safeMode, callerId, resolvePromise, pull, push, onSuccess, onError } = initObject; if (Object.keys(initObject).length < 13) { throw new SyncedObjectError(`Missing parameters in SyncedObject constructor. Use factory function initializedSyncedObject() instead.`, key, "initializeSyncedObject"); } this.key = key; this.type = type; this.data = data; this.changelog = changelog; this.debounceTime = debounceTime; this.reloadBehavior = reloadBehavior; this.safeMode = safeMode; this.callerId = callerId; this.resolvePromise = resolvePromise; if (pull) this.pull = pull; if (push) this.push = push; if (onSuccess) this.onSuccess = onSuccess; if (onError) this.onError = onError; this.state = { success: type === "temp" ? true : null, error: null }; } /** * The key associated with the synced object. * @type {string} */ key; /** * The type of the synced object. * @type {"temp"|"local"|"custom"} */ type; /** * The data of the synced object. * @type {*} */ data; /** * The changelog of properties pending sync. * - Useful to rerender components with certain property dependencies. * - Passed to custom sync functions. * @type {string[]} */ changelog; /** * The default sync debounce time. * - Used when no debounce time is provided to {@link SyncedObject.modify}. * - Future modify calls will reset the debounce timer. * @type {*} */ debounceTime; /** * The behavior upon attempted application unload. * - `prevent`: Stops a page unload with a default popup if the object is syncing. * - `allow`: Allows a page unload even if the object is syncing. * - `finish`: Attempts to force sync before page unload. * @type {"prevent"|"allow"|"finish"} */ reloadBehavior; /** * Whether safe mode checks and warnings are enabled. * - Defaults to `true` in development, `false` in production. * @type {boolean} */ safeMode; /** * The ID of the last component to modify this object. * @type {number} * @default null */ callerId; /** * The callback function called when an object of type `custom` tries to pull data. * @param {SyncedObject} syncedObject The synced object itself. * @returns {*} The data to be pulled, or null if a push is required. * @throws {Error} If there is an error pulling data. * @type {Function} * @default null */ pull; /** * The callback function called when an object of type `custom` tries to push data. * @param {SyncedObject} syncedObject The synced object itself. * @returns {*} Any * @throws {Error} If there is an error pushing data. * @type {Function} * @default null */ push; /** * The callback function called after a successful pull or push. * @param {SyncedObject} syncedObject The synced object itself. * @param {Object} status The status of the sync: { requestType, success, error }. * @type {Function} * @default null */ onSuccess; /** * The callback function called after an unsuccessful pull or push. * @param {SyncedObject} syncedObject The synced object itself. * @param {Object} status The status of the sync: { requestType, success, error }. * @type {Function} * @default null */ onError; /** * The state of the synced object. * @type {Object} * @property `success` Whether the last sync was successful. True, false, or null if syncing. * @property `error` The error of the last sync, else null. * @default success: null, error: null */ state; /** * A member function to handle modifications to the synced object. * @param {string|number|undefined} arg1 (Optional) The property to modify, or debounce time. * @param {number|undefined} arg2 (Optional) The debounce time, if property is provided. * @returns {Object} The synced object's data field. * @example * myObject.modify(); // Modifies 'myObject' with its default sync debounce time. Handles rerenders, syncing, and callbacks. * myObject.modify(1000).prop1 = "new value"; // Sets myObject.data.prop1 to "new value", modifying 'myObject' with a debounce time of 1000ms. * myObject.modify("prop1", 1000).prop1 = "new value"; // Sets the above, while modifying `myObject.prop1' with a debounce time of 1000ms. */ modify(arg1, arg2) { this.callerId = null; SyncedObjectManager.handleModifications(this, arg1, arg2); return this.data; } }