Skip to content

Commit

Permalink
refactor: simplified observer tracking
Browse files Browse the repository at this point in the history
Simplifies observer/dependency tracking by making signals the only bookkeepers of their observers.

Pros:
- Shaves off 300 bytes.
- Observer tracking and code is simpler to reason about.

Cons:
- Observers don't get cleared from memory after they're disposed of until the signal that is tracking them is written into again. This might cause a memory leak in an extreme case where there's a signal that doesn't change, but keeps getting more and more observers subscribed to it in a long observe→dispose chain, such as (P)React components mounting and unmounting.
  • Loading branch information
tomasklaen committed Sep 23, 2023
1 parent 2724fd2 commit bafbb4c
Show file tree
Hide file tree
Showing 4 changed files with 380 additions and 90 deletions.
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,18 @@
# statin

> Branch info:
>
> Simplifies observer/dependency tracking.
>
> Pros:
> - Shaves off 300 bytes.
> - Observer tracking and code is simpler to reason about.
>
> Cons:
> - Observers don't get cleared after they're disposed until the signal that is tracking them is written into again. This might cause a memory leak in an extreme case where there's a signal that doesn't change, but keeps getting more and more observers subscribed to it in a long observe→dispose chain, such as (P)React components mounting and unmounting.
>
> In practice, the leak issue _should_ hardly ever manifest itself into being noticeable, but it is a potential footgun, so this solution is going to remain in this branch as an alternative I felt bad about deleting.
Simple and tiny reactive state library.

Statin is heavily inspired by MobX. It was created as an attempt to get a MobX like reactivity in as little code as possible.
Expand Down
322 changes: 322 additions & 0 deletions index.old.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,322 @@
"use strict";
Object.defineProperty(exports, "__esModule", { value: true });
exports.once = exports.reaction = exports.createAction = exports.action = exports.computed = exports.signal = exports.toJS = exports.toJSON = exports.fnName = exports.nameFn = exports.CircularReactionError = void 0;
const IS_COMPUTED = Symbol('computed');
const IS_STATIN_ERROR = Symbol('statin_error');
const observersStack = [];
const dependencyMap = new WeakMap();
const observerMap = new WeakMap();
const nameCounters = {};
let effectQueue = null;
let doNotTrack = false;
let currentEffect;
const reactionsDepthMap = new Map();
const MAX_REACTION_DEPTH = 100;
class CircularReactionError extends Error {
constructor(message) {
super(message);
this[IS_STATIN_ERROR] = true;
}
}
exports.CircularReactionError = CircularReactionError;
function nameFn(name, fn) {
fn.displayName = name;
return fn;
}
exports.nameFn = nameFn;
function fnName(fn, fallbackName = 'Unknown') {
return ((fn && (fn.displayName || fn.name)) ||
`${fallbackName}${nameCounters[fallbackName] ? nameCounters[fallbackName]++ : (nameCounters[fallbackName] = 0)}`);
}
exports.fnName = fnName;
const isComputed = (value) => value[IS_COMPUTED] === true;
function describeError(maybeError, source) {
var _a;
if ((_a = maybeError) === null || _a === void 0 ? void 0 : _a[IS_STATIN_ERROR])
return maybeError;
const error = maybeError instanceof Error ? maybeError : new Error(`${maybeError}`);
error.message = `Error in ${source}: ${error.message}`;
error[IS_STATIN_ERROR] = true;
return error;
}
function registerDependency(dependency) {
const observer = observersStack[observersStack.length - 1];
if (!observer || doNotTrack)
return;
let observersSet = observerMap.get(dependency);
if (!observersSet) {
observersSet = new Set();
observerMap.set(dependency, observersSet);
}
observersSet.add(observer);
let dependencySet = dependencyMap.get(observer);
if (!dependencySet) {
dependencySet = new Set();
dependencyMap.set(observer, dependencySet);
}
dependencySet.add(dependency);
}
function clearDependencies(observer) {
let dependencySet = dependencyMap.get(observer);
if (!dependencySet)
return;
for (let dependency of dependencySet) {
const trackersSet = observerMap.get(dependency);
if (trackersSet) {
trackersSet.delete(observer);
if (trackersSet.size === 0)
observerMap.delete(dependency);
}
}
dependencyMap.delete(observer);
}
function triggerObservers(dependency) {
const observersSet = observerMap.get(dependency);
const currentObserver = observersStack[observersStack.length - 1];
if (observersSet) {
for (let observer of [...observersSet]) {
if (observer === currentObserver)
continue;
if (effectQueue && !isComputed(observer)) {
effectQueue.add(observer);
}
else {
observer();
}
}
}
}
function resumeTracking(fn) {
let parentDoNotTrack = doNotTrack;
doNotTrack = false;
try {
fn();
}
finally {
doNotTrack = parentDoNotTrack;
}
}
function toJSON(value) {
return (value === null || value === void 0 ? void 0 : value.toJSON) ? value.toJSON() : value;
}
exports.toJSON = toJSON;
function toJS(value) {
const stringified = JSON.stringify(value);
return stringified == null ? stringified : JSON.parse(stringified);
}
exports.toJS = toJS;
function handleOrLog(error, onError) {
try {
if (onError)
onError(error);
else
throw error;
}
catch (error) {
console.error(error);
}
}
function signal(value) {
function getSet(value) {
return arguments.length ? write(value) : read();
}
getSet.value = value;
getSet.changed = () => {
if (!effectQueue)
bulkEffects(() => triggerObservers(getSet));
else
triggerObservers(getSet);
};
getSet.edit = (editor) => {
editor(getSet.value);
getSet.changed();
};
getSet.toJSON = () => toJSON(read());
function read() {
registerDependency(getSet);
return getSet.value;
}
function write(value) {
if (value !== getSet.value) {
getSet.value = value;
getSet.changed();
}
}
return getSet;
}
exports.signal = signal;
function computed(compute) {
const name = fnName(compute, 'Computed');
let value;
let hasChanges = true;
let hasError = false;
let error;
function computedObserver() {
value = undefined;
hasError = false;
error = undefined;
hasChanges = true;
clearDependencies(computedObserver);
triggerObservers(computedObserver);
}
computedObserver.displayName = name;
computedObserver[IS_COMPUTED] = true;
function get() {
registerDependency(computedObserver);
if (hasChanges) {
observersStack.push(computedObserver);
try {
hasError = false;
error = undefined;
resumeTracking(() => (value = compute()));
}
catch (error_) {
hasError = true;
error = describeError(error_, name);
}
finally {
hasChanges = false;
observersStack.pop();
}
}
if (hasError)
throw error;
return value;
}
get.toJSON = () => toJSON(get());
return get;
}
exports.computed = computed;
function bulkEffects(effect, { onError } = {}) {
let isRootEffect = false;
if (effectQueue == null) {
isRootEffect = true;
effectQueue = new Set();
reactionsDepthMap.clear();
}
let result;
const parentEffect = currentEffect;
currentEffect = effect;
try {
result = effect();
}
catch (error) {
error = describeError(error, fnName(currentEffect));
if (onError)
onError(error);
else
console.error(error);
}
finally {
if (isRootEffect) {
while (effectQueue.size > 0) {
const effects = effectQueue;
effectQueue = new Set();
for (let effect of effects)
effect();
}
reactionsDepthMap.clear();
effectQueue = null;
}
currentEffect = parentEffect;
}
return result;
}
function action(fn) {
const parentDoNotTrack = doNotTrack;
doNotTrack = true;
try {
return bulkEffects(nameFn(fnName(fn, 'Action'), fn), {
onError: (error) => {
throw error;
},
});
}
finally {
doNotTrack = parentDoNotTrack;
}
}
exports.action = action;
function createAction(run) {
return (...args) => action(() => run(...args));
}
exports.createAction = createAction;
function reaction(action, effectOrOptions, options) {
let value;
let onceDisposer;
let effect;
if (typeof effectOrOptions === 'function') {
effect = effectOrOptions;
}
else {
options = effectOrOptions;
}
const { immediate, onError } = options || {};
const actionName = fnName(action, effect ? 'ReactionAction' : 'Reaction');
const effectName = fnName(effect, 'ReactionEffect');
const actionWrap = nameFn(actionName, (dispose) => {
value = action(dispose);
});
const effectWrap = nameFn(effectName, () => {
try {
effect === null || effect === void 0 ? void 0 : effect(value, dispose);
}
catch (error) {
handleError(describeError(error, effectName));
}
});
const onceEffect = nameFn(effectName, () => {
const stackSize = reactionsDepthMap.get(onceEffect) || 0;
if (stackSize > MAX_REACTION_DEPTH) {
throw new CircularReactionError(`Circular reaction in ${actionName}${effect ? `->${effectName}` : ''}:\n---\n${(effect || action).toString()}\n---`);
}
reactionsDepthMap.set(onceEffect, stackSize + 1);
createOnceLoop();
effectWrap();
});
function dispose() {
onceDisposer === null || onceDisposer === void 0 ? void 0 : onceDisposer();
}
function handleError(error) {
handleOrLog(error, onError ? (error) => onError(error, dispose) : undefined);
}
function createOnceLoop() {
onceDisposer = once(actionWrap, onceEffect, {
onError: (error, dispose) => {
onceDisposer = dispose;
handleError(error);
},
});
}
createOnceLoop();
if (immediate)
effectWrap();
return dispose;
}
exports.reaction = reaction;
function once(observe, effect, { onError } = {}) {
const dispose = () => clearDependencies(observer);
const observerName = fnName(observe, 'OnceObserver');
const effectName = fnName(effect, 'OnceEffect');
const errorHandler = onError ? (error) => onError(error, dispose) : undefined;
const observer = nameFn(observerName, () => {
dispose();
bulkEffects(effect, { onError: (error) => handleOrLog(describeError(error, effectName), errorHandler) });
});
observersStack.push(observer);
try {
let internalDisposerCalled = false;
const internalDisposer = () => (internalDisposerCalled = true);
resumeTracking(() => observe(internalDisposer));
if (internalDisposerCalled)
dispose();
}
catch (error) {
handleOrLog(describeError(error, observerName), errorHandler);
}
finally {
observersStack.pop();
}
return dispose;
}
exports.once = once;
Loading

0 comments on commit bafbb4c

Please sign in to comment.