enso
Version:
Maximalist state & form management library for React
767 lines (684 loc) • 22.9 kB
JavaScript
"use strict";
"use client";
exports.AtomProxyInternal = exports.AtomOptionalInternal = exports.AtomImpl = void 0;
var _alwaysly = require("alwaysly");
var _nanoid = require("nanoid");
var _react = require("react");
var _index = require("../change/index.cjs");
var _index2 = require("../detached/index.cjs");
var _index3 = require("../events/index.cjs");
var _index4 = require("../hooks/index.cjs");
var _rerender = require("../hooks/rerender.cjs");
var _index5 = require("./hooks/index.cjs");
var _index6 = require("./internal/array/index.cjs");
var _index7 = require("./internal/base/index.cjs");
var _index8 = require("./internal/index.cjs");
//#region AtomImpl
class AtomImpl {
//#region Static
static prop = "atom";
/**
* Creates and memoizes a new instance from the provided initial value.
* Just like `useState`, it will not recreate the instance on the value
* change.
*
* @param initialValue - Initial value.
* @param deps - Hook dependencies.
*
* @returns Memoized instance.
*/
static use(initialValue,
// TODO: Add tests
deps) {
const atom = (0, _index4.useMemo)(() => this.create(initialValue), deps);
(0, _react.useEffect)(() => () => atom.deconstruct(), [atom]);
return atom;
}
static create(value, parent) {
return new AtomImpl(value, parent);
}
/**
* Ensures that the atom is not undefined. It returns a tuple with ensured
* atom and dummy atom. If the atom is undefined, the dummy atom will
* return as the ensured, otherwise the passed atom.
*
* It allows to workaround the React Hooks limitation of not being able to
* call hooks conditionally.
*
* The dummy atom is frozen and won't change or trigger any events.
*
* @param atom - The atom to ensure. Can be undefined.
* @returns Atoms tuple, first element - ensured atom, second - dummy atom
*/
static useEnsure(atom, mapper) {
const dummy = this.use(undefined, []);
const frozenDummy = (0, _index4.useMemo)(() => Object.freeze(dummy), [dummy]);
const mappedAtom = mapper && atom && mapper(atom) || atom;
return mappedAtom || frozenDummy;
}
//#endregion
//#region Instance
constructor(value, parent) {
// this.#initial = value;
this.__parent = parent;
// NOTE: Parent **must** set before, so that when we setting the children
// values, the path is already set. If not, they won't properly register in
// the events tree.
this.#set(value);
this.events.add(this.path, this);
this.#try = this.#try.bind(this);
// this.set = this.set.bind(this);
// this.ref = this.ref.bind(this);
}
deconstruct() {
this.events.delete(this.path, this);
}
//#endregion
//#region Attributes
id = (0, _nanoid.nanoid)();
//#endregion
//#region Value
get value() {
if (this.#cachedGet === _index2.detachedValue) {
this.#cachedGet = this.internal.value;
}
return this.#cachedGet;
}
useValue(props) {
const watchAllMeta = !!props?.meta;
const watchMeta = watchAllMeta || props?.valid || props?.dirty;
const meta = this.useMeta(watchAllMeta ? undefined : {
dirty: !!props?.dirty,
errors: !!props?.errors,
valid: !!props?.valid
});
const getValue = (0, _index4.useCallback)(() => this.value, [
// eslint-disable-next-line react-hooks/exhaustive-deps -- It can't handle this
this]);
const watch = (0, _index4.useCallback)(({
valueRef,
rerender
}) => this.watch((payload, event) => {
// React only on structural changes
if (!(0, _index.structuralChanges)(event.changes)) return;
valueRef.current = {
id: this.id,
enable: true,
value: payload
};
rerender();
}), [
// eslint-disable-next-line react-hooks/exhaustive-deps -- It can't handle this
this]);
const toResult = (0, _index4.useCallback)(result => watchMeta ? [result, meta] : result, [meta, watchMeta]);
return (0, _index5.useAtomHook)({
atom: this,
getValue,
watch,
toResult
});
}
// TODO: Exposing the notify parents flag might be dangerous
set(value, notifyParents = true) {
const changes = this.#set(value);
if (changes) this.trigger(changes, notifyParents);
AtomImpl.lastChanges.set(this, changes);
return this;
}
#set(value) {
// Frozen atoms should not change!
if (Object.isFrozen(this)) return 0n;
const Internal = (0, _index8.detectInternalConstructor)(value);
// The atom is already of the same type
if (this.internal instanceof Internal) return this.internal.set(value);
// The atom is of a different type
this.internal.unwatch();
let changes = 0n;
// Atom is being detached
if (value === _index2.detachedValue) changes |= _index.change.atom.detach;
// Atom is being attached
else if (this.internal.detached()) changes |= _index.change.atom.attach;
// Atom type is changing
else changes |= _index.change.atom.type;
this.internal = new Internal(this, value);
this.internal.set(value);
return changes;
}
/**
* Paves the atom with the provided fallback value if the atom is undefined
* or null. It ensures that the atom has a value, which is useful when
* working with deeply nested optional objects, i.e., settings. It allows
* creating the necessary atoms to assign validation errors to them, even if
* the parents and the atom itself are not set.
*
* @param fallback - Fallback value to set if the atom is undefined or null.
*
* @returns Atom without null or undefined value in the type.
*/
pave(fallback) {
const value = this.value;
if (value === undefined || value === null) this.set(fallback);
return this;
}
compute(callback) {
return callback(this.value);
}
useCompute(callback, deps) {
const getValue = (0, _index4.useCallback)(() => callback(this.value),
// eslint-disable-next-line react-hooks/exhaustive-deps -- It can't handle this
[this, ...deps]);
return (0, _index5.useAtomHook)({
atom: this,
getValue
});
}
//#endregion
//#region Meta
useMeta() {
return {};
}
//#endregion
//#region Type
internal = new _index8.AtomInternalOpaque(this, _index2.detachedValue);
// Collection
get size() {
(0, _alwaysly.always)(this.internal instanceof _index8.AtomInternalCollection);
return this.internal.size;
}
remove(key) {
(0, _alwaysly.always)(this.internal instanceof _index8.AtomInternalCollection);
return this.internal.remove(key);
}
forEach(callback) {
(0, _alwaysly.always)(this.internal instanceof _index8.AtomInternalCollection);
this.internal.forEach(callback);
}
map(callback) {
(0, _alwaysly.always)(this.internal instanceof _index8.AtomInternalCollection);
return this.internal.map(callback);
}
find(predicate) {
(0, _alwaysly.always)(this.internal instanceof _index8.AtomInternalCollection);
return this.internal.find(predicate);
}
filter(predicate) {
(0, _alwaysly.always)(this.internal instanceof _index8.AtomInternalCollection);
return this.internal.filter(predicate);
}
self = {
try: () => {
if (this.value === undefined || this.value === null) return this.value;
return this;
},
remove: () => {
return this.set(_index2.detachedValue, true);
}
};
// Array
push(item) {
(0, _alwaysly.always)(this.internal instanceof _index6.AtomInternalArray);
return this.internal.push(item);
}
insert(index, item) {
(0, _alwaysly.always)(this.internal instanceof _index6.AtomInternalArray);
return this.internal.insert(index, item);
}
useCollection() {
const rerender = (0, _rerender.useRerender)();
(0, _react.useEffect)(() => this.watch((_, event) => {
if ((0, _index.shapeChanges)(event.changes)) rerender();
}), [this.id, rerender]);
return this;
}
//#endregion
//#region Tree
get root() {
return this.__parent && "source" in this.__parent ? this.__parent.source.root : this.__parent?.[this.#prop].root || this;
}
get parent() {
return this.__parent && "source" in this.__parent ? this.__parent.source.parent : this.__parent?.[this.#prop];
}
get key() {
if (!this.__parent) return;
return "source" in this.__parent ? this.__parent.source.key : this.__parent.key;
}
get $() {
return this.internal.$();
}
at(key) {
if (this.internal instanceof _index6.AtomInternalArray || this.internal instanceof _index8.AtomInternalObject) return this.internal.at(key);
// WIP:
// throw new Error(
// `Field at ${this.path.join(".")} is not an object or array`,
// );
}
#try = key => {
return this.internal.try(key);
};
get try() {
if (this.value && typeof this.value === "object") return this.#try;
}
get path() {
return this.__parent && "source" in this.__parent ? this.__parent.source.path : this.__parent ? [...this.__parent[this.#prop].path, this.__parent.key] : [];
}
get name() {
return AtomImpl.name(this.path);
}
static name(path) {
return path.join(".") || ".";
}
lookup(path) {
return this.internal.lookup(path);
}
//#endregion
//#region Ref
optional() {
return this.#static.optional({
type: "direct",
[this.#prop]: this
});
}
//#endregion
//#region Events
#batchTarget = new EventTarget();
#syncTarget = new EventTarget();
#subs = new Set();
#eventsTree;
// NOTE: Since `Atom.useEnsure` freezes the dummy atom but still allows
// running operations such as `set` albeit with no effect, we need to ensure
// that `lastChanges` is still assigned correctly, so we must use a static
// map instead of changing the atom instance directly.
static lastChanges = new WeakMap();
get lastChanges() {
return AtomImpl.lastChanges.get(this) || 0n;
}
get events() {
return this.root.#eventsTree ??= new _index3.EventsTree();
}
trigger(changes, notifyParents = false) {
this.clearCache();
if (this.#withholded) {
this.#withholded[0] |= changes;
if (this.#withholded[0] & _index.change.atom.valid && changes & _index.change.atom.invalid) this.#withholded[0] &= ~_index.change.atom.valid;
if (this.#withholded[0] & _index.change.atom.invalid && changes & _index.change.atom.valid) this.#withholded[0] &= ~(_index.change.atom.invalid | _index.change.atom.valid);
if (notifyParents) this.#withholded[1] = true;
} else {
_index.ChangesEvent.batch(this.#batchTarget, changes);
// TODO: Add tests for this
this.#syncTarget.dispatchEvent(new _index.ChangesEvent(changes));
}
// If the updates should flow upstream, trigger parents too
if (notifyParents && this.__parent && this.#prop in this.__parent &&
// @ts-expect-error
this.__parent[this.#prop])
// @ts-expect-error
this.__parent[this.#prop].#childTrigger(changes, this.__parent.key);
}
#childTrigger(childChanges, key) {
let changes =
// Shift child's atom changes into child/subtree range
(0, _index.shiftChildChanges)(childChanges) |
// Apply atom changes
this.internal.childUpdate(childChanges, key);
// Apply shape change
changes |= (0, _index.shapeChanges)(changes);
this.trigger(changes, true);
}
watch(callback, sync = false) {
// TODO: Add tests for this
const target = sync ? this.#syncTarget : this.#batchTarget;
const handler = event => {
callback(this.value, event);
};
this.#subs.add(handler);
target.addEventListener("change", handler);
return () => {
this.#subs.delete(handler);
target.removeEventListener("change", handler);
};
}
useWatch(callback) {
// Preserve id to detected the active atom swap.
const idRef = (0, _react.useRef)(this.id);
(0, _react.useEffect)(() => {
// If the atom id changes, trigger the callback with the swapped change.
if (idRef.current !== this.id) {
idRef.current = this.id;
callback(this.value, new _index.ChangesEvent(_index.change.atom.id));
}
return this.watch(callback);
}, [this.id, callback]);
}
unwatch() {
// TODO: Add tests for this
this.#subs.forEach(sub => {
this.#batchTarget.removeEventListener("change", sub);
this.#syncTarget.removeEventListener("change", sub);
});
this.#subs.clear();
this.internal.unwatch();
}
#withholded;
/**
* Withholds the atom changes until `unleash` is called. It allows to batch
* changes when submitting a form and send the submitting even to the atom
* along with the submitting value.
*
* TODO: I added automatic batching of changes, so all the changes are send
* after the current stack is cleared. Check if this functionality is still
* needed.
*/
withhold() {
this.#withholded = [0n, false];
this.internal.withhold();
}
unleash() {
this.internal.unleash();
const withholded = this.#withholded;
this.#withholded = undefined;
if (withholded?.[0]) this.trigger(...withholded);
}
//#endregion
//#region Transform
into(intoMapper) {
return {
from: fromMapper => this.#static.proxy(this, intoMapper, fromMapper)
};
}
// TODO: Add tests
useInto(intoMapper, intoDeps) {
const from = (0, _index4.useCallback)((fromMapper, fromDeps) => {
const computed = (0, _index4.useMemo)(() => this.#static.proxy(this, intoMapper, fromMapper),
// eslint-disable-next-line react-hooks/exhaustive-deps -- We control `intoMapper` and `fromMapper` via `intoDeps` and `fromDeps`.
[this, ...intoDeps, ...fromDeps]);
(0, _react.useEffect)(() => computed.deconstruct.bind(computed), [computed]);
return computed;
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- We control `intoMapper` via `intoDeps`
[this, ...intoDeps]);
return (0, _index4.useMemo)(() => ({
from
}), [from]);
}
decompose() {
return {
value: this.value,
[this.#prop]: this
};
}
useDecompose(callback, deps) {
const getValue = (0, _index4.useCallback)(() => this.decompose(), [
// eslint-disable-next-line react-hooks/exhaustive-deps -- It can't handle this
this]);
const shouldRender = (0, _index4.useCallback)((prev, next) => !!prev && callback(next.value, prev.value),
// eslint-disable-next-line react-hooks/exhaustive-deps -- It can't handle this
deps);
return (0, _index5.useAtomHook)({
atom: this,
getValue,
shouldRender
});
}
decomposeNullish() {
return {
value: this.value,
[this.#prop]: this
};
}
useDecomposeNullish(callback, deps) {
const getValue = (0, _index4.useCallback)(() => this.decomposeNullish(), [
// eslint-disable-next-line react-hooks/exhaustive-deps -- It can't handle this
this]);
const shouldRender = (0, _index4.useCallback)((prev, next) => !!prev && next.value == null !== (prev.value == null),
// eslint-disable-next-line react-hooks/exhaustive-deps -- It can't handle this
deps);
return (0, _index5.useAtomHook)({
atom: this,
getValue,
shouldRender
});
}
discriminate(discriminator) {
const value = this.value;
return {
// NOTE: We use value and `&&` instead of optional chaining to preserve
// null as a valid discriminator value.
discriminator: value && value[discriminator],
[this.#prop]: this
};
}
useDiscriminate(discriminator) {
const getValue = (0, _index4.useCallback)(() => this.discriminate(discriminator), [
// eslint-disable-next-line react-hooks/exhaustive-deps -- It can't handle this
this, discriminator]);
const shouldRender = (0, _index4.useCallback)((prev, next) => prev?.discriminator !== next.discriminator, []);
return (0, _index5.useAtomHook)({
atom: this,
getValue,
shouldRender
});
}
useDefined(type) {
const maybeNullish = (0, _react.useRef)(this.value);
return this.useInto(value => {
switch (type) {
case "string":
return value ?? "";
case "array":
return value ?? [];
case "object":
return value ?? {};
}
}, []).from(value => {
// If the value not nullish, return it
if (value) return value;
// Restore original value if it was nullish
if (!maybeNullish.current) return maybeNullish.current;
// Otherwise, return the original value, which should be ""
return value;
}, []);
}
shared() {
return this;
}
//#endregion
//#region External
#external = {
move: newKey => {
(0, _alwaysly.always)(this.__parent && this.#prop in this.__parent);
const prevPath = this.path;
this.__parent.key = newKey;
this.events.move(prevPath, this.path, this);
return this.trigger(_index.atomChange.key);
},
create: value => {
const changes = this.#set(value) | _index.change.atom.attach;
this.trigger(changes, false);
return changes;
},
clear: () => {
this.#set(undefined);
}
};
get [_index7.externalSymbol]() {
return this.#external;
}
//#endregion
//#region Cache
#cachedGet = _index2.detachedValue;
clearCache() {
this.#cachedGet = _index2.detachedValue;
}
//#endregion
//#region Atom
get #static() {
return this.constructor;
}
get #prop() {
return this.#static.prop;
}
//#endregion
}
//#region
//#region AtomProxyInternal
exports.AtomImpl = AtomImpl;
class AtomProxyInternal {
// External atom proxy implementation instance, i.e., StateProxyImpl or FieldProxyImpl
#external;
#source;
#brand = Symbol();
#into;
#from;
#unsubs = [];
constructor(external, source, into, from) {
this.#external = external;
this.#source = source;
this.#into = into;
this.#from = from;
// Watch for the atom (source) and update the computed value
// on structural changes.
this.#unsubs.push(this.#source.watch((sourceValue, sourceEvent) => {
// Check if the change was triggered by the computed value and ignore
// it to stop circular updates.
if (sourceEvent.context[this.#brand]) return;
// Update the computed value if the change is structural.
// TODO: Tests
if ((0, _index.structuralChanges)(sourceEvent.changes)) {
// TODO: Second argument is unnecessary expensive and probably can
// be replaced with simple atom.
this.#external.set(this.#into(sourceValue, this.#external.value));
}
},
// TODO: Add tests and rationale for this. Without it, though, when
// rendering collection settings in Mind Control and disabling a package
// that triggers rerender and makes the computed atom set to initial
// value. The culprit is "Prevent extra mapper call" code above that
// resets parent computed atom value before it gets a chance to update.
true));
// Listen for the computed atom changes and update the atom
// (source) value.
this.#unsubs.push(this.#external.watch((computedValue, computedEvent) => {
// Check if the change was triggered by the source value and ignore it
// to stop circular updates.
if (computedEvent.context[this.#brand]) return;
// Set context so we can know if the atom change was triggered by
// the computed value and stop circular updates.
_index.ChangesEvent.context({
[this.#brand]: true
}, () => {
// If there are structural changes, update the source atom.
// // TODO: Tests
if ((0, _index.structuralChanges)(computedEvent.changes)) {
// TODO: Second argument is unnecessary expensive and probably can
// be replaced with simple atom.
this.#source.set(this.#from(computedValue, this.#source.value));
}
// Trigger meta changes.
// TODO: Add tests and rationale for this.
const computedMetaChanges = (0, _index.metaChanges)(computedEvent.changes);
if (computedMetaChanges) {
this.#source.trigger(computedMetaChanges,
// TODO: Add tests and rationale for this (see a todo above).
true);
}
});
},
// TODO: Add tests and rationale for this (see a todo above).
true));
}
deconstruct() {
this.#source.constructor.prototype.deconstruct.call(this.#external);
this.#unsubs.forEach(unsub => unsub());
this.#unsubs = [];
}
//#region Computed
connect(source) {
this.#source = source;
}
}
//#endregion
//#region AtomOptionalInternal
exports.AtomProxyInternal = AtomProxyInternal;
class AtomOptionalInternal {
//#region Static
static instances = new WeakMap();
static instance(atom) {
let ref = AtomOptionalInternal.instances.get(atom);
console.log("===", ref);
if (!ref) {
ref = atom.constructor.optional({
type: "direct",
[atom.constructor.prop]: atom
});
console.log("~~~", ref);
AtomOptionalInternal.instances.set(atom, ref);
}
return ref;
}
//#endregion
#external;
#target;
constructor(external, target) {
this.#external = external;
this.#target = target;
this.#try = this.#try.bind(this);
}
get value() {
return AtomOptionalInternal.value(this.#prop, this.#target);
}
//#region Value
static value(prop, target) {
if (target.type !== "direct") return undefined;
return target[prop].value;
}
//#endregion
//#region Tree
at(key) {
let target;
if (this.#target.type === "direct") {
const atom = this.#target[this.#prop].at(key);
target = atom ? {
type: "direct",
[this.#prop]: atom
} : {
type: "shadow",
closest: this.#target[this.#prop],
path: [key]
};
} else {
target = {
type: "shadow",
closest: this.#target.closest,
path: [...this.#target.path, String(key)]
};
}
return this.#external.constructor.optional(target);
}
#try = key => {
// If it is a shadow atom, there can't be anything to try.
if (this.#target.type !== "direct") return;
const atom = this.#target[this.#prop].try?.(
// @ts-expect-error
key);
return atom && AtomOptionalInternal.instance(atom);
};
get try() {
return this.#try;
}
//#endregion
//#region Optional
get path() {
return this.#target.type === "direct" ? this.#target[this.#prop].path : [...this.#target.closest.path, ...this.#target.path];
}
get root() {
return this.#target.type === "direct" ? this.#target[this.#prop].root : this.#target.closest.root;
}
//#e
//#region External
get #prop() {
return this.#external.constructor.prop;
}
//#endregion
}
//#endregion
exports.AtomOptionalInternal = AtomOptionalInternal;