UNPKG

@idiosync/react-observable

Version:

State management control layer for React projects

538 lines (537 loc) 22.1 kB
"use strict"; Object.defineProperty(exports, "__esModule", { value: true }); exports.createObservable = void 0; const general_1 = require("../utils/general"); const general_2 = require("../utils/general"); const stream_1 = require("../utils/stream"); const observable_1 = require("../types/observable"); const createObservable = (params) => { var _a, _b; const initialValue = params === null || params === void 0 ? void 0 : params.initialValue; const equalityFn = params === null || params === void 0 ? void 0 : params.equalityFn; const name = params === null || params === void 0 ? void 0 : params.name; const isFlushable = (_a = params === null || params === void 0 ? void 0 : params.isFlushable) !== null && _a !== void 0 ? _a : true; const emitWhenValuesAreEqual = (_b = params === null || params === void 0 ? void 0 : params.emitWhenValuesAreEqual) !== null && _b !== void 0 ? _b : false; const id = (0, general_2.uuid)(); let _emitCount = 0; let _observableName = name !== null && name !== void 0 ? name : id; let _listenerRecords = []; let isStreamCancelled = false; const manualHaltEvent = { reason: observable_1.StreamHaltReason.Manual, isTerminal: false, }; const cancelledHaltEvent = { reason: observable_1.StreamHaltReason.Cancelled, isTerminal: true, }; const createStack = (stack, errorMessage, error) => { var _a; const lastStackItem = stack === null || stack === void 0 ? void 0 : stack[stack.length - 1]; const splitName = name === null || name === void 0 ? void 0 : name.split('_'); const isCatchObservable = (splitName === null || splitName === void 0 ? void 0 : splitName[splitName.length - 1]) === 'catchError'; // this is a bit of a hack to ensure the stack takes account // of errors that are restored by the catchError operator // TODO - need to find a better way to handle this const addErrorToStream = errorMessage ? true : ((_a = ((lastStackItem === null || lastStackItem === void 0 ? void 0 : lastStackItem.isError) && !isCatchObservable)) !== null && _a !== void 0 ? _a : false); return [ ...(stack !== null && stack !== void 0 ? stack : []), { id, name: _observableName, emitCount: _emitCount++, isError: addErrorToStream, errorMessage, error, }, ]; }; const getInitialValue = () => (0, general_1.isFunction)(initialValue) ? initialValue() : initialValue; const getEmitCount = () => _emitCount; const getIsStreamCancelled = () => isStreamCancelled; let value = getInitialValue(); const get = () => value; const emit = (stack) => { if (isStreamCancelled) { return; } const newStack = createStack(stack); const unsubscribeIds = _listenerRecords.reduce((acc, { listener, once, id }) => { listener === null || listener === void 0 ? void 0 : listener(value, newStack); return once ? [...acc, id] : acc; }, []); unsubscribeIds.forEach((id) => unsubscribe(id)); }; const emitError = (err, stack) => { var _a, _b; if (isStreamCancelled) { return; } const newStack = createStack(stack, (_b = (_a = err.message) !== null && _a !== void 0 ? _a : err.name) !== null && _b !== void 0 ? _b : err.toString(), err); _listenerRecords.forEach(({ onError }) => onError === null || onError === void 0 ? void 0 : onError(err, newStack)); }; /** * emitStreamHalted notifies subscribers that an emission halted. Terminal * halt events, such as cancellation, permanently stop future emissions. */ const emitStreamHalted = (stack, event = manualHaltEvent) => { if (isStreamCancelled) { return; } if (event.isTerminal) { isStreamCancelled = true; } const newStack = createStack(stack); _listenerRecords.forEach(({ onStreamHalted }) => onStreamHalted === null || onStreamHalted === void 0 ? void 0 : onStreamHalted(newStack, event)); }; const cancelStream = (stack) => { emitStreamHalted(stack, cancelledHaltEvent); }; const _setInternal = (isSilent) => (newValue, stack) => { if (isStreamCancelled) { return false; } const reducedValue = ((0, general_1.isFunction)(newValue) ? newValue(get()) : newValue); if ((equalityFn && equalityFn(value, reducedValue)) || (!emitWhenValuesAreEqual && value === reducedValue)) { if (!isSilent) { // if silent then we can assume the user knows // what they want to (or not to) emit emitStreamHalted(stack, { reason: observable_1.StreamHaltReason.Unchanged, isTerminal: false, }); } return false; } value = reducedValue; if (!isSilent) { emit(stack); } return true; }; const set = _setInternal(false); const setSilent = _setInternal(true); const subscribe = (listener, onError, onStreamHalted) => { const id = (0, general_2.uuid)(); _listenerRecords.push({ listener, onError, id, once: false, onStreamHalted, }); return () => unsubscribe(id); }; const subscribeOnce = (listener, onError, onStreamHalted) => { const id = (0, general_2.uuid)(); _listenerRecords.push({ listener, onError, id, once: true, onStreamHalted }); return () => unsubscribe(id); }; const subscribeWithValue = (listener, onError, onStreamHalted) => { const unsubscribe = subscribe(listener, onError, onStreamHalted); if (listener && !isStreamCancelled) { listener(value); } return unsubscribe; }; const unsubscribe = (id) => { _listenerRecords = _listenerRecords.filter((lr) => lr.id !== id); }; const forwardStreamHalt = (target) => (stack, event) => { if ((event === null || event === void 0 ? void 0 : event.reason) === observable_1.StreamHaltReason.Cancelled) { target.cancelStream(stack); return; } target.emitStreamHalted(stack, event); }; const combineLatestFrom = (...observables) => { const { initialValues, subscribeFunctions } = observables.reduce((acc, obs) => { acc.initialValues.push(obs.get()); acc.subscribeFunctions.push(obs.subscribe); return acc; }, { initialValues: [get()], subscribeFunctions: [subscribe], }); const combinationObservable$ = (0, exports.createObservable)({ initialValue: initialValues, emitWhenValuesAreEqual, name: `${name}_combineLatestFrom:${observables.map((obs) => obs.getName()).join(',')}:${name}`, }); subscribeFunctions.forEach((sub, i) => { sub((val, stack) => { if (combinationObservable$.getIsStreamCancelled()) { return; } combinationObservable$.set((values) => { const clone = [...values]; clone[i] = val; return clone; }, stack); }, (err) => combinationObservable$.emitError(err), forwardStreamHalt(combinationObservable$)); }); return combinationObservable$; }; const withLatestFrom = (...observables) => { const resultObservable$ = (0, exports.createObservable)({ initialValue: [ get(), ...observables.map((obs) => obs.get()), ], emitWhenValuesAreEqual, name: `${name}_withLatestFrom:${observables.map((obs) => obs.getName()).join(',')}:${name}`, }); subscribe((sourceValue, stack) => { if (resultObservable$.getIsStreamCancelled()) { return; } const combined = [ sourceValue, ...observables.map((obs) => obs.get()), ]; resultObservable$.set(combined, stack); }, resultObservable$.emitError, forwardStreamHalt(resultObservable$)); return resultObservable$; }; const stream = (project, { initialValue, streamedName, executeOnCreation = true, } = {}) => { const name = streamedName !== null && streamedName !== void 0 ? streamedName : (0, stream_1.createStreamName)(getName()); const newObservable$ = (0, exports.createObservable)({ initialValue: (initialValue !== null && initialValue !== void 0 ? initialValue : undefined), name, emitWhenValuesAreEqual, }); (executeOnCreation ? subscribeWithValue : subscribe)((data, stack) => { if (newObservable$.getIsStreamCancelled()) { return; } const [newData, projectError] = (0, general_2.tryCatchSync)(() => project(data), `Stream Error: Attempt to project stream to "${name}" from "${getName()}" has failed.`); if (projectError) { newObservable$.emitError(projectError, stack); } else { newObservable$.set(newData, stack); } }, newObservable$.emitError, forwardStreamHalt(newObservable$)); return newObservable$; }; const streamAsync = (project, { initialValue, streamedName, executeOnCreation = false, } = {}) => { const name = streamedName !== null && streamedName !== void 0 ? streamedName : (0, stream_1.createStreamName)(getName()); const newObservable$ = (0, exports.createObservable)({ initialValue: (initialValue !== null && initialValue !== void 0 ? initialValue : undefined), name, emitWhenValuesAreEqual, }); let nextSequence = 0; let isProcessingQueue = false; const queue = []; const processQueue = async () => { if (isProcessingQueue || newObservable$.getIsStreamCancelled()) { return; } const nextItem = queue.shift(); if (!nextItem) { return; } isProcessingQueue = true; const [newData, error] = await (0, general_2.tryCatch)(() => project(nextItem.data), `Stream Error: Attempt to project stream to "${name}" from "${getName()}" has failed.`); isProcessingQueue = false; if (newObservable$.getIsStreamCancelled()) { queue.length = 0; return; } if (error) { newObservable$.emitError(error, nextItem.stack); } else { newObservable$.set(newData, nextItem.stack); } processQueue(); }; const projectToNewObservable = (data, stack) => { if (newObservable$.getIsStreamCancelled()) { return; } queue.push({ data, stack, sequence: nextSequence++ }); processQueue(); }; (executeOnCreation ? subscribeWithValue : subscribe)(projectToNewObservable, newObservable$.emitError, (stack, event) => { if ((event === null || event === void 0 ? void 0 : event.reason) === observable_1.StreamHaltReason.Cancelled) { queue.length = 0; } forwardStreamHalt(newObservable$)(stack, event); }); return newObservable$; }; const tap = (callback) => { const newObservable$ = (0, exports.createObservable)({ initialValue: get(), name: `${name}_tap`, emitWhenValuesAreEqual, }); subscribe((val, stack) => { if (newObservable$.getIsStreamCancelled()) { return; } callback(val); newObservable$.setSilent(val, stack); // here we force the emit regardless of the emitWhenValuesAreEqual flag // but pass the origional flag downstream newObservable$.emit(stack); }, newObservable$.emitError, forwardStreamHalt(newObservable$)); return newObservable$; }; const delay = (milliseconds) => { // False is used here because the type is already inferred // and adding true would cause the type to be inferred as NullableInferredT | undefined const newObservable$ = (0, exports.createObservable)({ initialValue: get(), name: `${name}_after-delay-${milliseconds}`, emitWhenValuesAreEqual, }); let isProcessingDelayQueue = false; const queue = []; const processQueue = async () => { if (isProcessingDelayQueue || newObservable$.getIsStreamCancelled()) { return; } const nextItem = queue.shift(); if (!nextItem) { return; } isProcessingDelayQueue = true; await new Promise((r) => setTimeout(r, milliseconds)); isProcessingDelayQueue = false; if (newObservable$.getIsStreamCancelled()) { queue.length = 0; return; } newObservable$.set(nextItem.value, nextItem.stack); processQueue(); }; subscribe((val, stack) => { if (newObservable$.getIsStreamCancelled()) { return; } queue.push({ value: val, stack }); processQueue(); }, newObservable$.emitError, (stack, event) => { if ((event === null || event === void 0 ? void 0 : event.reason) === observable_1.StreamHaltReason.Cancelled) { queue.length = 0; } forwardStreamHalt(newObservable$)(stack, event); }); return newObservable$; }; const throttle = (milliseconds) => { const newObservable$ = (0, exports.createObservable)({ initialValue: get(), name: `${name}_throttle_${milliseconds}`, emitWhenValuesAreEqual, }); let lastEmittedAt = 0; const shouldEmit = (now) => lastEmittedAt === 0 || now - lastEmittedAt >= milliseconds; subscribeWithValue((val, stack) => { if (newObservable$.getIsStreamCancelled()) { return; } const now = Date.now(); if (shouldEmit(now)) { lastEmittedAt = now; newObservable$.set(val, stack); } }, newObservable$.emitError, forwardStreamHalt(newObservable$)); return newObservable$; }; const mapEntries = ({ keys, observablePostfix = '$', } = {}) => { const currentValue = get(); if (!(0, general_1.isObject)(currentValue)) { throw new Error(`mapEntries can only be used on object observables: ${getName()} is a ${typeof currentValue}`); } const entries = Object.entries(currentValue); const filteredEntries = keys ? entries.filter(([key]) => keys.includes(key)) : entries; return filteredEntries.reduce((acc, [key, value]) => { const name = `${key}${observablePostfix}`; return { ...acc, [name]: stream((val) => val[key], { streamedName: name, }), }; }, {}); }; /** * catchError allows you to intercept errors in the observable stream. * * - The user-provided onError handler can choose to: * - Throw a new error (for better debugging or to mark a problem section) * - Forward the original error * - Do nothing, in which case the stream will halt gracefully via emitStreamHalted * - If the user handler throws, that error is emitted downstream. * * This design allows liberal use of throws throughout the stream, and helps pinpoint problem sections by allowing custom errors to be thrown at any catchError boundary. If the handler does nothing, the stream halts (no longer emits a special error). */ const catchError = (onError) => { const newObservable$ = (0, exports.createObservable)({ initialValue: get(), name: `${name}_catchError`, emitWhenValuesAreEqual, }); const handleError = (error, stack) => { if (newObservable$.getIsStreamCancelled()) { return; } if (onError) { try { const errorResolution = onError(error, get()); if (errorResolution) { newObservable$.set(errorResolution.restoreValue, stack); } else { newObservable$.emitStreamHalted(stack, { reason: observable_1.StreamHaltReason.ErrorHandled, isTerminal: false, }); } } catch (err) { newObservable$.emitError(err, stack); } } else { newObservable$.emitStreamHalted(stack, { reason: observable_1.StreamHaltReason.ErrorHandled, isTerminal: false, }); } }; subscribe((val, stack) => { if (newObservable$.getIsStreamCancelled()) { return; } newObservable$.set(val, stack); }, handleError, forwardStreamHalt(newObservable$)); return newObservable$; }; /** * finally (name when exported) allows you to execute a callback for all subscription events: * - onValue: when a new value is emitted * - onError: when an error occurs * - onHalt: when an emission halts without cancelling the stream * - onCancel: when the stream is cancelled * * This is useful for cleanup operations, logging, or any side effects * that need to happen regardless of the subscription outcome. */ const final = (callback) => { const newObservable$ = (0, exports.createObservable)({ initialValue: get(), name: `${name}_finally`, emitWhenValuesAreEqual, }); subscribe((val, stack) => { if (newObservable$.getIsStreamCancelled()) { return; } callback('onValue', val, undefined, stack); newObservable$.set(val, stack); }, (error, stack) => { if (newObservable$.getIsStreamCancelled()) { return; } callback('onError', undefined, error, stack); newObservable$.emitError(error, stack); }, (stack, event) => { if (newObservable$.getIsStreamCancelled()) { return; } const haltEvent = event !== null && event !== void 0 ? event : manualHaltEvent; callback(haltEvent.reason === observable_1.StreamHaltReason.Cancelled ? 'onCancel' : 'onHalt', undefined, undefined, stack, haltEvent); if (haltEvent.reason === observable_1.StreamHaltReason.Cancelled) { newObservable$.cancelStream(stack); } else { newObservable$.emitStreamHalted(stack, haltEvent); } }); return newObservable$; }; const guard = (predicate) => { // Create a new observable for the guarded stream // False is used here because the type is already inferred // and adding true would cause the type to be inferred as NullableInferredT | undefined const guardedObservable = (0, exports.createObservable)({ initialValue: get(), emitWhenValuesAreEqual, name: `${name}_guard`, }); // Subscribe to the original observable observable.subscribe((nextValue, stack) => { if (guardedObservable.getIsStreamCancelled()) { return; } const prevValue = guardedObservable.get(); if (predicate(nextValue, prevValue)) { guardedObservable.set(nextValue, stack); } else { // The value is not passed through, but an error must be guardedObservable.emitStreamHalted(stack, { reason: observable_1.StreamHaltReason.GuardRejected, isTerminal: false, }); } }, guardedObservable.emitError, forwardStreamHalt(guardedObservable)); return guardedObservable; }; const reset = () => set(getInitialValue()); const getName = () => _observableName; const setName = (name) => { _observableName = name; }; const getId = () => id; const getIsFlushable = () => isFlushable; const observable = { get, set, setSilent, getEmitCount, subscribe, subscribeOnce, subscribeWithValue, stream, streamAsync, // TODO- this is not currently type safe combineLatestFrom, withLatestFrom, tap, delay, throttle, catchError, reset, getName, setName, getId, emit, emitError, emitStreamHalted, cancelStream, mapEntries, getInitialValue, guard, finally: final, getIsFlushable, getIsStreamCancelled, }; return observable; }; exports.createObservable = createObservable;