UNPKG

enso

Version:

Maximalist state & form management library for React

767 lines (684 loc) 22.9 kB
"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;