@aegisjsproject/state
Version:
A simple state manager library
648 lines (579 loc) • 22.9 kB
JavaScript
const stateRegistry = new Map();
const channel = new BroadcastChannel('aegis:state_sync');
const sender = crypto.randomUUID();
const proxySymbol = Symbol('proxy');
const updateSymbol = Symbol('aegis:state:update');
let isChannelOpen = true;
export const EVENT_TARGET = new EventTarget();
export const stateKey = 'aegisStateKey';
export const stateAttr = 'aegisStateAttr';
export const stateStyle = 'aegisStateStyle';
export const stateProperty = 'aegisStateProperty';
export const stateKeyAttribute = 'data-aegis-state-key';
export const stateAttrAttribute = 'data-aegis-state-attr';
export const statePropertyAttr = 'data-aegis-state-property';
export const stateStyleAttribute = 'data-aegis-state-style';
export const changeEvent = 'change';
export const beforeChangeEvent = 'beforechange';
const _getState = (key, fallback = null) => history.state?.[key] ?? fallback;
function $$(selector, base = document.documentElement) {
const results = base.querySelectorAll(selector);
return base.matches(selector) ? [base, ...results] : Array.from(results);
}
async function _updateElement({ state = history.state ?? {} } = {}) {
const key = this.dataset.aegisStateKey;
const val = state?.[key];
await scheduler?.yield();
if (typeof this.dataset[stateAttr] === 'string') {
const attr = this.dataset[stateAttr];
const oldVal = this.getAttribute(attr);
if (typeof oldVal === 'string' && oldVal.startsWith('blob:')) {
URL.revokeObjectURL(oldVal);
}
if (typeof val === 'boolean') {
this.toggleAttribute(attr, val);
} else if (val === null || val === undefined) {
this.removeAttribute(attr);
} else if (val instanceof Blob) {
this.setAttribute(attr, URL.createObjectURL(val));
} else {
this.setAttribute(attr, val);
}
} else if (typeof this.dataset[stateProperty] === 'string' && this.dataset[stateProperty] !== 'innerHTML') {
this[this.dataset[stateProperty]] = val;
} else if (typeof this.dataset[stateStyle] === 'string') {
if (typeof val === 'undefined' || val === null || val === false) {
this.style.removeProperty(this.dataset[stateStyle]);
} else {
this.style.setProperty(this.dataset[stateStyle], val);
}
} else if (this instanceof HTMLInputElement || this instanceof HTMLSelectElement || this instanceof HTMLTextAreaElement) {
this.value = val;
} else {
this.textContent = val;
}
}
const domObserver = new MutationObserver(mutations => {
mutations.forEach(record => {
switch(record.type) {
case 'childList':
record.addedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
$$(`[${stateKeyAttribute}]`, node.target).forEach(el => {
el[updateSymbol] = _updateElement.bind(el);
observeStateChanges(el[updateSymbol], el.dataset[stateKey]);
});
}
});
record.removedNodes.forEach(node => {
if (node.nodeType === Node.ELEMENT_NODE) {
$$(`[${stateKeyAttribute}]`, node.target).forEach(el => {
unobserveStateChanges(el[updateSymbol]);
delete el[updateSymbol];
});
}
});
break;
case 'attributes':
if (typeof record.oldValue === 'string') {
unobserveStateChanges(record.target[updateSymbol]);
delete record.target[updateSymbol];
} else if (typeof record.target.dataset[stateKey] === 'string') {
record.target[updateSymbol] = _updateElement.bind(record.target);
observeStateChanges(record.target[updateSymbol], record.target.dataset[stateKey]);
}
break;
}
});
});
function _getStateMessage(type, recipient, data = {}) {
return {
sender, type, state: getStateObj(), msgId: crypto.randomUUID(),
recipient, location: location.href, timestamp: Date.now(), ...data
};
}
function _updateState(state = getStateObj(), url = location.href) {
const diff = diffState(state);
if (diff.length !== 0) {
history.replaceState(state, '', url);
notifyStateChange(diff);
return true;
} else {
return false;
}
}
/**
* Closes the broadcast channel if it's currently open. This will stop syncing between browsing contexts (tabs/windows/iframes)
*/
export function closeChannel() {
if (isChannelOpen) {
channel.close();
isChannelOpen = false;
}
}
/**
* Calculates the difference between two state objects.
*
* @param {object} newState - The new state object.
* @param {object} [oldState] - The old state object. Defaults to the current state object.
* @returns {string[]} - An array of keys representing the added, removed, or changed properties.
*/
export function diffState(newState, oldState = getStateObj()) {
if (oldState !== newState) {
const oldKeys = Object.keys(oldState);
const newKeys = Object.keys(newState);
const addedKeys = newKeys.filter(key => ! oldKeys.includes(key));
const removedKeys = oldKeys.filter(key => ! newKeys.includes(key));
const changedKeys = oldKeys.filter(key => key in newState && key in oldState && newState[key] !== oldState[key]);
return Object.freeze([...addedKeys, ...changedKeys, ...removedKeys]);
} else {
return [];
}
}
/**
* Notifies registered state change callbacks about changes in the state object.
*
* @param {string[]} diff - An array of keys representing the added, removed, or changed properties.
* @returns {Promise<object[]>} - A promise that resolves to an array of settlement objects, one for each callback invocation.
*/
export async function notifyStateChange(diff) {
if (Array.isArray(diff) && diff.length !== 0) {
const currState = getStateObj();
const state = Object.fromEntries(diff.map(key => [key, currState[key]]));
await Promise.allSettled(Array.from(
stateRegistry.entries(),
([callback, observedStates]) => {
if (observedStates.length === 0 || observedStates.some(state => diff.includes(state))) {
callback({ diff, state });
}
}
));
}
}
/**
* Registers a callback function to be notified of state changes.
*
* @param {Function} target - The callback function to register.
* @param {string[]} observedStates - An array of state keys to observe.
* @returns {boolean} - True if the callback was successfully registered, false otherwise.
*/
export function observeStateChanges(target, ...observedStates) {
if (target instanceof Function && ! stateRegistry.has(target)) {
stateRegistry.set(target, observedStates);
return true;
} else {
return false;
}
};
/**
* Gets a state value associated with a given key, providing a proxy object for reactive access and modification.
*
* @param {string} key - The key of the state value to retrieve.
* @param {*} [fallback=null] - The fallback value to return if the state value is undefined or null.
* @returns {ProxyHandler} - A proxy object representing the state value.
*/
export function getState(key, fallback = null) {
return new Proxy({
toString() {
return _getState(key, fallback)?.toString() ?? '';
},
valueOf() {
const val = _getState(key, fallback);
return val?.valueOf instanceof Function ? val.valueOf() : val;
},
toJSON() {
return _getState(key, fallback);
},
[Symbol.toPrimitive](hint) {
const val = _getState(key, fallback);
if (typeof val === hint) {
return val;
} else if (hint === 'default' && typeof val !== 'object') {
return val;
} else if (val?.[Symbol.toPrimitive] instanceof Function) {
return val?.[Symbol.toPrimitive] instanceof Function ? val[Symbol.toPrimitive](hint) : val;
} else if (hint !== 'number' && val?.toString instanceof Function) {
return val.toString();
} else if (hint === 'number') {
return parseFloat(val);
} else {
return val;
}
},
[proxySymbol]: true,
[Symbol.toStringTag]: 'StateValue',
[Symbol.iterator]() {
return _getState(key, fallback)?.[Symbol.iterator]();
},
}, {
defineProperty(target, prop, attributes) {
const val = _getState(key, fallback);
if (Reflect.defineProperty(val, prop, attributes)) {
setState(key, val);
return val;
} else {
return false;
}
},
deleteProperty(target, prop) {
return Reflect.deleteProperty(_getState(key, fallback), prop);
},
get(target, prop) {
const val = _getState(key, fallback);
if (prop in target) {
return target[prop];
} else if (typeof val === 'object') {
const result = Reflect.get(val, prop, val);
return result instanceof Function ? result.bind(val) : result;
} else {
return val[prop];
}
},
getOwnPropertyDescriptor(target, prop) {
return Reflect.getOwnPropertyDescriptor(_getState(key, fallback), prop);
},
getPrototypeOf() {
const val = _getState(key, fallback);
return typeof val === 'object' ? Reflect.getPrototypeOf(val) : Object.getPrototypeOf(val);
},
has(target, prop) {
return Reflect.has(_getState(key, fallback), prop);
},
isExtensible() {
return Reflect.isExtensible(_getState(key, fallback));
},
ownKeys() {
return Reflect.ownKeys(_getState(key, fallback));
},
preventExtensions() {
return Reflect.preventExtensions(_getState(key, fallback));
},
set(target, prop, newValue) {
const val = _getState(key, fallback);
if (Reflect.set(val, prop, newValue, val)) {
setState(key, val);
return true;
} else {
return false;
}
}
});
}
/**
* Unregisters a callback function from being notified of state changes.
*
* @param {Function} target - The callback function to unregister.
* @returns {boolean} - True if the callback was successfully unregistered, false otherwise.
*/
export const unobserveStateChanges = target => stateRegistry.delete(target);
/**
* Gets the current state object from the history.
*
* @returns {object} - A frozen copy of the current state object.
*/
export const getStateObj = () => Object.freeze(history.state === null ? {} : structuredClone(history.state));
/**
* Checks if a state key exists in the current state object.
*
* @param {string} key - The key to check for.
* @returns {boolean} - True if the key exists, false otherwise.
*/
export const hasState = key => key in getStateObj();
/**
* Sets a state value.
*
* @param {string} key - The property name to set.
* @param {*} newValue - The new value for the property, or a function to call to update state
*/
export function setState(key, newValue) {
const state = getStateObj();
if (typeof newValue === 'function') {
updateState(key, newValue);
} else if (state[key] !== newValue) {
const detail = { key, oldValue: state[key], newValue };
const event = new CustomEvent(beforeChangeEvent, { cancelable: true, detail });
EVENT_TARGET.dispatchEvent(event);
if (! event.defaultPrevented) {
replaceState({ ...getStateObj(), [key]: newValue?.[proxySymbol] ? newValue.valueOf() : newValue }, location.href);
EVENT_TARGET.dispatchEvent(new CustomEvent(changeEvent, { detail }));
}
}
};
/**
* Updates a state value asynchronously.
*
* @param {string} key - The key of the state value to update.
* @param {Function} cb - The callback function to update the value.
* @returns {Promise<*>} - A promise that resolves to the updated state value.
*/
export const updateState = async (key, cb) => await Promise.try(() => cb(_getState(key), key)).then(val => {
setState(key, val);
return val;
});
/**
* Manages a state value, providing a getter and setter functions.
*
* @param {string} key - The key of the state value.
* @param {*} [initialValue=null] - The initial value for the state value.
* @returns {[ProxyHandler, Function]} - An array containing the getter and setter functions. The first function returns a proxy object representing the state value, and the second function is used to update the value.
*/
export function manageState(key, initialValue = null) {
return [getState(key, initialValue), newVal => setState(key, newVal)];
};
/**
* Deletes a state value.
*
* @param {string} key - The key of the state value to delete.
*/
export function deleteState(key) {
const state = { ...getStateObj() };
delete state[key];
replaceState(state, location.href);
};
/**
* Saves the current state object to local storage.
*
* @param {string} [key="aegis:state"] - The key to use for storing the state in local storage.
*/
export const saveState = (key = 'aegis:state') => localStorage.setItem(key, JSON.stringify(getStateObj()));
/**
* Restores the state object from local storage.
*
* @param {string} [key="aegis:state"] - The key used for storing the state in local storage.
*/
export const restoreState = (key = 'aegis:state') => _updateState(JSON.parse(localStorage.getItem(key)), location.href);
/**
* Clears the current state object.
*/
export const clearState = () => replaceState({}, location.href);
/**
* Replaces the current state object with the given state and updates the URL.
*
* @param {Object} state - The new state object.
* @param {string} url - The new URL.
* @returns {boolean} - True if the state was successfully replaced, false otherwise.
*/
export function replaceState(state = getStateObj(), url = location.href) {
if (_updateState(state, url)) {
if (isChannelOpen) {
channel.postMessage(_getStateMessage('update'));
}
}
}
/**
* Watches for state updates broadcasted through the channel and applies them to the local state.
*
* @param {object} [options] - Optional options.
* @param {AbortSignal} [options.signal] - An AbortSignal to cancel the watcher.
*/
export function watchState({ signal } = {}) {
channel.addEventListener('message', event => {
if (
event.isTrusted
&& typeof event.data.msgId === 'string'
&& typeof event.data.sender === 'string'
&& event.data.sender !== sender
&& typeof event.data.state === 'object'
&& (typeof event.data.recipient !== 'string' || event.data.recipient === sender)
) {
const currentState = getStateObj();
const diff = diffState(event.data.state, currentState);
if (diff.length !== 0) {
switch(event.data.type) {
case 'update':
_updateState({ ...currentState, ...event.data.state }, location.href);
break;
case 'sync':
if (isChannelOpen) {
channel.postMessage(_getStateMessage('update', event.data.sender));
}
break;
case 'clear':
_updateState({}, location.href);
break;
default:
reportError(new Error(`Unhandled broadcast channel message type: ${event.data.type}`));
}
}
}
}, { signal });
channel.postMessage(_getStateMessage('sync'));
if (signal instanceof AbortSignal && signal.aborted) {
closeChannel();
} else if (signal instanceof AbortSignal) {
signal.addEventListener('abort', closeChannel, { once: true });
}
};
/**
* Watches for DOM mutations (added/removed nodes and attribute changes) for elements matching `[data-aegis-state-key]`.
* Matching elements register a callback to be updated on state changes
*
* @param {Element|ShadowRoot|string} [target=document.documentElement] Root element to observe from
* @param {object} options
* @param {AbortSignal} [options.signal] Optional signal to disconnect the observer on abort
* @param {Element} [options.base=document.documentElement] Base element to query from when `target` is a selector
* @throws {TypeError} If the `target` is not an Element, ShadowRoot, or a valid CSS selector.
* @throws {Error} If the provided `signal` is aborted.
*/
export function observeDOMState(target = document.documentElement, { signal, base = document.documentElement } = {}) {
if (signal instanceof AbortSignal && signal.aborted) {
throw signal.reason;
} else if (typeof target === 'string') {
observeDOMState(base.querySelector(target), { signal });
} else if (! (target instanceof Element || target instanceof ShadowRoot)) {
throw new TypeError('Target must be an element, selector, or shadow root.');
} else {
domObserver.observe(target, {
childList: true,
subtree: true,
attributeFilter: [stateKeyAttribute],
attributeOldValue: true,
});
$$(`[${stateKeyAttribute}]`, target).forEach(el => {
el[updateSymbol] = _updateElement.bind(el);
observeStateChanges(el[updateSymbol], el.dataset[stateKey]);
el[updateSymbol]({ state: history.state });
});
if (signal instanceof AbortSignal) {
signal.addEventListener('abort', () => domObserver.disconnect(), { once: true });
}
}
}
/**
* Binds a DOM element to a specific state key to be updated on state changes
*
* @param {Element|string} target Target element or a selector
* @param {string} key Name/key to observe
* @param {object} options
* @param {string} [options.attr] Optional attribute to bind state to
* @param {string} [options.style] Optional style property to bind state to
* @param {Element} [options.base=document.body] Base to query from when `target` is a selector
*/
export function bindState(target, key, { attr, style, base = document.body } = {}) {
if (typeof target === 'string') {
bindState(base.querySelector(target), key, { attr, style });
} else if (! (target instanceof Element)) {
throw new TypeError('Target must be an element or selector.');
} else if (typeof stateKey !== 'string' || stateKey.length === 0) {
throw new TypeError('State key must be a non-empty string.');
} else if (target instanceof HTMLElement) {
target.dataset[stateKey] = key;
if (typeof attr === 'string') {
target.dataset[stateAttr] = attr;
} else if (typeof style === 'string') {
target.dataset[stateStyle] = style;
}
requestAnimationFrame(() => _updateElement.call(target, { state: history.state ?? {}}));
} else if (target instanceof Element) {
target.setAttribute(stateKeyAttribute, key);
if (typeof attr === 'string') {
target.setAttribute(stateAttrAttribute, attr);
} else if (typeof style === 'string') {
target.setAttribute(stateStyleAttribute, style);
}
requestAnimationFrame(() => _updateElement.call(target, { state: history.state ?? {}}));
}
}
/**
* Creates and registers a callback on for given element (`target`) for state changes specified by `key`
*
* @param {Element|string} target Element or selector
* @param {string} key Name/value for key in state obejct
* @param {Function} handler The callback to register on for state changes
* @param {object} options
* @param {Element} [options.base=document.body] Base to query from when `target` is a selector
* @param {AbortSignal} [options.signal] Optional signal to unregister callback when aborted
* @returns {Function} The resulting callback, bound to the target Element
*/
export function createStateHandler(target, key, handler, { base = document.documentElement, signal } = {}) {
if (signal instanceof AbortSignal && signal.aborted) {
throw signal.reason;
} else if (typeof target === 'string') {
return createStateHandler(base.querySelector(target), key, handler, {});
} else if (! (target instanceof Element)) {
throw new TypeError('Target must be an element or selector.');
} else if (typeof key !== 'string' || key.length === 0) {
throw new TypeError('State key must be a non-empty string.');
} else if (! (handler instanceof Function)) {
throw new TypeError('Callback must be a function.');
} else {
const callback = (function({ state = {} } = {}) {
return handler.call(this, state[key], this);
}).bind(target);
observeStateChanges(callback, key);
if (signal instanceof AbortSignal) {
signal.addEventListener('abort', () => unobserveStateChanges(callback), { once: true });
}
return callback;
}
}
/**
* A change or input handler for inputs, updating state to new values
*
* @param {Event} event A change or input event
* @throws {TypeError} If the event target is not an HTMLElement
*/
export function changeHandler({ target, type }) {
if (! (target instanceof HTMLElement)) {
throw new TypeError(`Event ${type} target must be an HTMLElement.`);
} else if (target instanceof HTMLSelectElement) {
setState(target.name, target.multiple ? Array.from(target.selectedOptions, opt => opt.value) : target.value);
} else if (target instanceof HTMLInputElement) {
switch(target.type) {
case 'checkbox': {
const checkboxes = Array.from(target.form?.elements ?? [target]).filter(input => input.name === target.name && input.type === 'checkbox');
if (checkboxes.length === 1) {
setState(target.name, target.value === 'on' ? target.checked : target.value);
} else {
setState(target.name, Array.from(checkboxes).filter(item => item.checked).map(item => item.value));
}
}
break;
case 'number':
case 'range':
setState(target.name, target.valueAsNumber);
break;
case 'date':
setState(target.name, target.valueAsDate?.toISOString()?.split('T')?.at(0));
break;
case 'file':
setState(target.name, target.multiple ? Array.from(target.files) : target.files.item(0));
break;
case 'datetime-local':
setState(target.name, target.valueAsDate);
break;
default:
setState(target.name, target.value);
}
} else if (target instanceof HTMLTextAreaElement) {
setState(target.name, target.value);
} else if (target.constructor.formAssociated) {
setState(target.name, target.value);
} else {
throw new TypeError(`Event ${type} target is not a valid form element.`);
}
}
/**
* Adds an event listener for a `change` event on state.
*
* @param {Function} callback - The callback function to handle the `change` event.
* @param {object} [options] - Optional configuration object to customize the listener behavior.
* @param {AbortSignal} [options.signal] - An optional `AbortSignal` object that allows you to cancel the event listener (useful for cleanup).
* @param {boolean} [options.once=false] - If `true`, the listener will be invoked at most once and then removed after the first invocation.
* @param {boolean} [options.passive=false] - If `true`, the listener will never call `preventDefault()`, improving performance for some types of events (e.g., scrolling).
*/
export function onStateChange(callback, { signal, once = false, passive = false } = {}) {
EVENT_TARGET.addEventListener(changeEvent, callback, { signal, once, passive });
}
/**
* Adds an event listener for a cancelable `beforechange` event on state
*
* @param {Function} callback - The callback function to handle the `beforechange` event.
* @param {object} [options] - Optional configuration object to customize the listener behavior.
* @param {AbortSignal} [options.signal] - An optional `AbortSignal` object that allows you to cancel the event listener (useful for cleanup).
* @param {boolean} [options.once=false] - If `true`, the listener will be invoked at most once and then removed after the first invocation.
* @param {boolean} [options.passive=false] - If `true`, the listener will never call `preventDefault()`, improving performance for some types of events (e.g., scrolling).
*/
export function onBeforeStateChange(callback, { signal, once = false, passive = false } = {}) {
EVENT_TARGET.addEventListener(beforeChangeEvent, callback, { signal, once, passive });
}