@idiosync/react-observable
Version:
State management control layer for React projects
366 lines (365 loc) • 15.8 kB
JavaScript
;
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 createObservable = (params) => {
var _a;
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 emitWhenValuesAreEqual = (_a = params === null || params === void 0 ? void 0 : params.emitWhenValuesAreEqual) !== null && _a !== void 0 ? _a : false;
const id = (0, general_2.uuid)();
let _emitCount = 0;
let _observableName = name !== null && name !== void 0 ? name : id;
let _listenerRecords = [];
const createStack = (stack, errorMessage) => {
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,
},
];
};
const getInitialValue = () => (0, general_1.isFunction)(initialValue)
? initialValue()
: initialValue;
const getEmitCount = () => _emitCount;
let value = getInitialValue();
const get = () => value;
const emit = (stack) => {
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;
const newStack = createStack(stack, (_b = (_a = err.message) !== null && _a !== void 0 ? _a : err.name) !== null && _b !== void 0 ? _b : err.toString());
_listenerRecords.forEach(({ onError }) => onError === null || onError === void 0 ? void 0 : onError(err, newStack));
};
/**
* emitStreamHalted notifies all subscribers that the stream has been halted.
* After calling emitStreamHalted, no further values or errors will be emitted.
* Subscribers can provide an onStreamHalted callback to react to stream halting.
*/
const emitStreamHalted = (stack) => {
const newStack = createStack(stack);
_listenerRecords.forEach(({ onStreamHalted }) => onStreamHalted === null || onStreamHalted === void 0 ? void 0 : onStreamHalted(newStack));
};
const _setInternal = (isSilent) => (newValue, stack) => {
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);
}
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) {
listener(value);
}
return unsubscribe;
};
const unsubscribe = (id) => {
_listenerRecords = _listenerRecords.filter((lr) => lr.id !== id);
};
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) => {
combinationObservable$.set((values) => {
const clone = [...values];
clone[i] = val;
return clone;
}, stack);
}, (err) => combinationObservable$.emitError(err), combinationObservable$.emitStreamHalted);
});
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) => {
const combined = [
sourceValue,
...observables.map((obs) => obs.get()),
];
resultObservable$.set(combined, stack);
}, resultObservable$.emitError, resultObservable$.emitStreamHalted);
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) => {
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, newObservable$.emitStreamHalted);
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,
});
const projectToNewObservable = async (data, stack) => {
const [newData, error] = await (0, general_2.tryCatch)(() => project(data), `Stream Error: Attempt to project stream to "${name}" from "${getName()}" has failed.`);
if (error) {
newObservable$.emitError(error, stack);
}
else {
newObservable$.set(newData, stack);
}
};
(executeOnCreation ? subscribeWithValue : subscribe)(projectToNewObservable, newObservable$.emitError, newObservable$.emitStreamHalted);
return newObservable$;
};
const tap = (callback) => {
const newObservable$ = (0, exports.createObservable)({
initialValue: get(),
name: `${name}_tap`,
emitWhenValuesAreEqual,
});
subscribe((val, stack) => {
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, newObservable$.emitStreamHalted);
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,
});
subscribe(async (val, stack) => {
await new Promise((r) => setTimeout(r, milliseconds));
newObservable$.set(val, stack);
}, newObservable$.emitError, newObservable$.emitStreamHalted);
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 (onError) {
try {
const errorResolution = onError(error, get());
if (errorResolution) {
newObservable$.set(errorResolution.restoreValue, stack);
}
else {
newObservable$.emitStreamHalted(stack);
}
}
catch (err) {
newObservable$.emitError(err, stack);
}
}
else {
newObservable$.emitStreamHalted(stack);
}
};
subscribe((val, stack) => newObservable$.set(val, stack), handleError, newObservable$.emitStreamHalted);
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
* - onComplete: when the stream is halted
*
* 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) => {
callback('onValue', val, undefined, stack);
newObservable$.set(val, stack);
}, (error, stack) => {
callback('onError', undefined, error, stack);
newObservable$.emitError(error, stack);
}, (stack) => {
callback('onComplete', undefined, undefined, stack);
newObservable$.emitStreamHalted(stack);
});
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) => {
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);
}
}, guardedObservable.emitError, guardedObservable.emitStreamHalted);
return guardedObservable;
};
const reset = () => set(getInitialValue());
const getName = () => _observableName;
const setName = (name) => {
_observableName = name;
};
const getId = () => id;
const observable = {
get,
set,
setSilent,
getEmitCount,
subscribe,
subscribeOnce,
subscribeWithValue,
stream,
streamAsync,
// TODO- this is not currently type safe
combineLatestFrom,
withLatestFrom,
tap,
delay,
catchError,
reset,
getName,
setName,
getId,
emit,
emitError,
emitStreamHalted,
mapEntries,
getInitialValue,
guard,
finally: final,
};
return observable;
};
exports.createObservable = createObservable;