react-synced-object
Version:
A lightweight, efficient, and versatile package for seamless state synchronization across a React application.
204 lines (198 loc) • 9.75 kB
JavaScript
import { useState, useEffect, useMemo } from 'react';
import { SyncedObjectManager, SyncedObjectError, SyncedObject } from './SyncedObjectManager';
/**
* @typedef {Object} returnBundle
* @property {SyncedObject|null} syncedObject - The {@link SyncedObject} if it exists.
* @property {Object|null} syncedData - The synced object's data.
* @property {boolean|null} syncedSuccess - The success state of the last sync attempt: either true, false, or null if syncing.
* @property {Error|null} syncedError - The error object generated from the last sync attempt, if any.
* @property {function(string|number|undefined, number|undefined): syncedData} modify - A function for modifying the synced object, with the same arguments as {@link SyncedObject.modify}.
*/
/**
* A custom hook for interacting with an existing synced object through a component.
* @param {string} key
* @param {Object} [options]
* @param {string|string[]} [options.dependencies=["modify", "pull", "push", "error"]]
* @param {string|string[]} [options.properties=[""]]
* @param {boolean} [options.safeMode=true | false]
* @returns {returnBundle} Several methods and properties for interacting with the synced object.
* - `syncedObject`: The {@link SyncedObject} if it exists.
* - `syncedData`: The synced object's data.
* - `syncedSuccess`: The success state of the last sync attempt: either true, false, or null if syncing.
* - `syncedError`: The error object generated from the last sync attempt, if any.
* - `modify`: A function for modifying the synced object, with the same arguments as {@link SyncedObject.modify}.
* @example
* const { syncedObject, syncedData, syncedSuccess, syncedError, modify } = useSyncedObject("myObject");
*
*/
const useSyncedObject = (key, options) => {
// Checks:
if (typeof useState !== 'function' || typeof useEffect !== 'function' || typeof useMemo !== 'function') {
throw new SyncedObjectError('This version of React does not support the required hooks: [useState, useEffect, useMemo]', key, 'useSyncedObject');
}
// Setup:
const [rerender, setRerender] = useState(0);
const [componentId, setComponentId] = useState(-1);
const handleProps = (key, options) => {
const result = {};
// Validate props:
if (!key) {
throw new SyncedObjectError("useSyncedObject hook error: key is required", key, "useSyncedObject");
}
if (!options) {
return result;
}
const safeMode = options.safeMode === undefined ? SyncedObjectManager.globalSafeMode : options.safeMode;
result.safeMode = safeMode;
if (options.dependencies) {
if (safeMode) {
if (typeof options.dependencies === "string") {
options.dependencies = [options.dependencies];
}
if (Array.isArray(options.dependencies)) {
result.dependencies = [];
}
else {
throw new SyncedObjectError("useSyncedObject hook error: options.dependencies must be an string or array of strings", key, "useSyncedObject");
}
// Loop through array, check each string:
options.dependencies.forEach((dependency) => {
if (dependency === "modify" || dependency === "modify_external" || dependency === "push" || dependency === "pull" || dependency === "error") {
result.dependencies.push(dependency);
}
else {
throw new SyncedObjectError("useSyncedObject hook error: options.dependencies strings must be one of: 'modify', 'modify_external', 'push', 'pull', 'error'", key, "useSyncedObject");
}
});
}
else {
if (typeof options.dependencies === "string") {
result.dependencies = [options.dependencies];
}
else {
result.dependencies = options.dependencies;
}
}
}
if (options.properties) {
if (safeMode) {
if (typeof options.properties === "string") {
options.properties = [options.properties];
}
if (Array.isArray(options.properties)) {
result.properties = [];
}
else {
throw new SyncedObjectError("useSyncedObject hook error: options.properties must be an string or array of strings", key, "useSyncedObject");
}
// Loop through array, check each string:
options.properties.forEach((properties) => {
if (typeof properties === "string") {
result.properties.push(properties);
}
else {
throw new SyncedObjectError("useSyncedObject hook error: options.properties must be an string or array of strings", key, "useSyncedObject");
}
});
}
else {
if (typeof options.properties === "string") {
result.properties = [options.properties];
}
else {
result.properties = options.properties;
}
}
}
return result;
};
const { dependencies = ["modify", "pull", "push", "error"], properties = [""], safeMode }
= useMemo(() => handleProps(key, options), [key]);
useEffect(() => {
// Initialize synced object:
const syncedObject = SyncedObjectManager.getSyncedObject(key);
setSyncedObject(syncedObject);
setSyncedData(syncedObject?.data);
setSyncedSuccess(syncedObject?.state.success);
setSyncedError(syncedObject?.state.error);
}, [rerender]);
useEffect(() => {
// Checks:
if (safeMode && !SyncedObjectManager.getSyncedObject(key)) {
console.warn("useSyncedObject hook warning: key '" + key + "' does not exist in SyncedObjectManager. Initialize before usage, if possible. ");
}
// Add event listener, setup componentId:
let componentId = SyncedObjectManager.generateComponentId();
setComponentId(componentId);
const eventHandler = (event) => {
// Key check:
if (event.detail.key !== key) {
return;
}
// Delete check:
if (event.detail.requestType === "delete") {
setRerender(rerender => rerender + 1);
return;
}
// Dependency checks:
if (event.detail.requestType === "modify" &&
(dependencies.includes("modify") ||
(dependencies.includes("modify_external") && event.detail.callerId !== componentId))) {
// Property checks:
const changelogEmpty = event.detail.changelog.length === 0;
const propertiesEmpty = properties.length === 0;
const propertiesContainsEmptyString = properties.includes("");
if (changelogEmpty) {
// modify() will rerender properties = [] || [""] || ["", "myProp"], but not properties = ["myProp"].
if (propertiesEmpty || propertiesContainsEmptyString) {
setRerender(rerender => rerender + 1);
}
return;
}
else {
// modify("myProp") will rerender properties = [""] || [..., "myProp"], but not ["", "myProp2"].
const changelogContainsProperty = event.detail.changelog.some(element => properties.includes(element));
if (changelogContainsProperty || (propertiesContainsEmptyString && properties.length === 1)) {
setRerender(rerender => rerender + 1);
}
return;
}
}
if (event.detail.requestType === "push" && dependencies.includes("push") ||
event.detail.requestType === "pull" && dependencies.includes("pull")) {
setRerender(rerender => rerender + 1);
return;
}
if (syncedError !== event.detail.error && dependencies.includes("error")) {
setRerender(rerender => rerender + 1);
return;
}
}
document.addEventListener('syncedObjectEvent', eventHandler);
// Cleanup:
return () => {
document.removeEventListener('syncedObjectEvent', eventHandler);
};
}, [key]);
// Interface:
const object = SyncedObjectManager.getSyncedObject(key);
const [syncedObject, setSyncedObject] = useState(object);
const [syncedData, setSyncedData] = useState(object?.data);
const [syncedSuccess, setSyncedSuccess] = useState(object?.state.success);
const [syncedError, setSyncedError] = useState(object?.state.error);
const modify = (arg1, arg2) => {
if (!syncedObject) return;
syncedObject.callerId = componentId;
SyncedObjectManager.handleModifications(syncedObject, arg1, arg2);
return syncedObject.data;
};
// Exports:
return {
syncedObject,
syncedData,
syncedSuccess,
syncedError,
modify,
};
};
export default useSyncedObject;