diff --git a/package.json b/package.json index 3bf0c1789e..bb03b7d28a 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,6 @@ "build:vanilla:utils": "rollup -c --config-vanilla_utils", "build:react": "rollup -c --config-react --client-only", "build:react:utils": "rollup -c --config-react_utils --client-only", - "build:experimental": "rollup -c --config-experimental", "postbuild": "pnpm patch-d-ts && pnpm copy && pnpm patch-ts3.8 && pnpm patch-old-ts && pnpm patch-esm-ts && pnpm patch-readme", "prettier": "prettier '*.{js,json,md}' '{src,tests,benchmarks,docs}/**/*.{ts,tsx,md,mdx}' --write", "eslint": "eslint --fix --no-eslintrc --c .eslintrc.json '*.{js,json,ts}' '{src,tests,benchmarks}/**/*.{ts,tsx}'", @@ -80,7 +79,7 @@ "test:format": "prettier '*.{js,json,md}' '{src,tests,benchmarks,docs}/**/*.{ts,tsx,md,mdx}' --list-different", "test:types": "tsc --noEmit", "test:lint": "eslint --no-eslintrc --c .eslintrc.json '*.{js,json,ts}' '{src,tests,benchmarks}/**/*.{ts,tsx}'", - "test:spec": "vitest run && USE_STORE2=true vitest run", + "test:spec": "vitest run", "test-build:spec": "vitest run", "patch-d-ts": "node -e \"var {entries}=require('./rollup.config.js');require('shelljs').find('dist/**/*.d.ts').forEach(f=>{entries.forEach(({find,replacement})=>require('shelljs').sed('-i',new RegExp(' from \\''+find.source.slice(0,-1)+'\\';$'),' from \\''+replacement+'\\';',f));require('shelljs').sed('-i',/ from '(\\.[^']+)\\.ts';$/,' from \\'\\$1\\';',f)})\"", "copy": "shx cp -r dist/src/* dist/esm && shx cp -r dist/src/* dist && shx rm -rf dist/src && shx rm -rf dist/{src,tests} && downlevel-dts dist dist/ts3.8 --to=3.8 && shx cp package.json readme.md LICENSE dist && json -I -f dist/package.json -e \"this.private=false; this.devDependencies=undefined; this.optionalDependencies=undefined; this.scripts=undefined; this.prettier=undefined;\"", diff --git a/rollup.config.js b/rollup.config.js index f7e9368155..afd185f7a6 100644 --- a/rollup.config.js +++ b/rollup.config.js @@ -76,7 +76,6 @@ function createESMConfig(input, output, clientOnly) { 'import.meta.env?.MODE': '(import.meta.env ? import.meta.env.MODE : undefined)', }), - 'import.meta.env?.USE_STORE2': 'false', delimiters: ['\\b', '\\b(?!(\\.|/))'], preventAssignment: true, }), @@ -96,7 +95,6 @@ function createCommonJSConfig(input, output, clientOnly) { resolve({ extensions }), replace({ 'import.meta.env?.MODE': 'process.env.NODE_ENV', - 'import.meta.env?.USE_STORE2': 'false', delimiters: ['\\b', '\\b(?!(\\.|/))'], preventAssignment: true, }), @@ -134,7 +132,6 @@ function createUMDConfig(input, output, env, clientOnly) { resolve({ extensions }), replace({ 'import.meta.env?.MODE': JSON.stringify(env), - 'import.meta.env?.USE_STORE2': 'false', delimiters: ['\\b', '\\b(?!(\\.|/))'], preventAssignment: true, }), @@ -158,7 +155,6 @@ function createSystemConfig(input, output, env, clientOnly) { resolve({ extensions }), replace({ 'import.meta.env?.MODE': JSON.stringify(env), - 'import.meta.env?.USE_STORE2': 'false', delimiters: ['\\b', '\\b(?!(\\.|/))'], preventAssignment: true, }), diff --git a/src/experimental.ts b/src/experimental.ts deleted file mode 100644 index 3b17070f81..0000000000 --- a/src/experimental.ts +++ /dev/null @@ -1,11 +0,0 @@ -export { atom } from './vanilla/atom.ts' -export type { Atom, WritableAtom, PrimitiveAtom } from './vanilla/atom.ts' -export { createStore, getDefaultStore } from './vanilla/store2.ts' -export type { - Getter, - Setter, - ExtractAtomValue, - ExtractAtomArgs, - ExtractAtomResult, - SetStateAction, -} from './vanilla/typeUtils.ts' diff --git a/src/types.d.ts b/src/types.d.ts index e099c12ad6..f10fd4e38b 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -1,6 +1,5 @@ declare interface ImportMeta { env?: { MODE: string - USE_STORE2?: string } } diff --git a/src/vanilla.ts b/src/vanilla.ts index 5169a4c30d..7c799d6d08 100644 --- a/src/vanilla.ts +++ b/src/vanilla.ts @@ -1,17 +1,7 @@ export { atom } from './vanilla/atom.ts' export type { Atom, WritableAtom, PrimitiveAtom } from './vanilla/atom.ts' -// export { createStore, getDefaultStore } from './vanilla/store.ts' -import * as store from './vanilla/store.ts' -import * as store2 from './vanilla/store2.ts' -type CreateStore = typeof store.createStore -type GetDefaultStore = typeof store.getDefaultStore -export const createStore: CreateStore = import.meta.env?.USE_STORE2 - ? store2.createStore - : store.createStore -export const getDefaultStore: GetDefaultStore = import.meta.env?.USE_STORE2 - ? store2.getDefaultStore - : store.getDefaultStore +export { createStore, getDefaultStore } from './vanilla/store.ts' export type { Getter, diff --git a/src/vanilla/store.ts b/src/vanilla/store.ts index fef404fb40..f8d317d07d 100644 --- a/src/vanilla/store.ts +++ b/src/vanilla/store.ts @@ -19,128 +19,236 @@ const hasInitialValue = >( const isActuallyWritableAtom = (atom: AnyAtom): atom is AnyWritableAtom => !!(atom as AnyWritableAtom).write -type CancelPromise = (next?: Promise) => void -const cancelPromiseMap: WeakMap, CancelPromise> = new WeakMap() +// +// Continuable Promise +// -const registerCancelPromise = ( - promise: Promise, - cancel: CancelPromise, -) => { - cancelPromiseMap.set(promise, cancel) - promise.catch(() => {}).finally(() => cancelPromiseMap.delete(promise)) -} +const CONTINUE_PROMISE = Symbol( + import.meta.env?.MODE !== 'production' ? 'CONTINUE_PROMISE' : '', +) -const cancelPromise = (promise: Promise, next?: Promise) => { - const cancel = cancelPromiseMap.get(promise) - if (cancel) { - cancelPromiseMap.delete(promise) - cancel(next) +const PENDING = 'pending' +const FULFILLED = 'fulfilled' +const REJECTED = 'rejected' + +type ContinuePromise = ( + nextPromise: PromiseLike | undefined, + nextAbort: () => void, +) => void + +type ContinuablePromise = Promise & + ( + | { status: typeof PENDING } + | { status: typeof FULFILLED; value?: T } + | { status: typeof REJECTED; reason?: AnyError } + ) & { + [CONTINUE_PROMISE]: ContinuePromise } -} -type PromiseMeta = { - status?: 'pending' | 'fulfilled' | 'rejected' - value?: T - reason?: AnyError - orig?: PromiseLike -} +const isContinuablePromise = ( + promise: unknown, +): promise is ContinuablePromise => + typeof promise === 'object' && promise !== null && CONTINUE_PROMISE in promise -const resolvePromise = (promise: Promise & PromiseMeta, value: T) => { - promise.status = 'fulfilled' - promise.value = value -} +const continuablePromiseMap: WeakMap< + PromiseLike, + ContinuablePromise +> = new WeakMap() -const rejectPromise = ( - promise: Promise & PromiseMeta, - e: AnyError, -) => { - promise.status = 'rejected' - promise.reason = e +/** + * Create a continuable promise from a regular promise. + */ +const createContinuablePromise = ( + promise: PromiseLike, + abort: () => void, + complete: () => void, +): ContinuablePromise => { + if (!continuablePromiseMap.has(promise)) { + let continuePromise: ContinuePromise + const p: any = new Promise((resolve, reject) => { + let curr = promise + const onFulfilled = (me: PromiseLike) => (v: T) => { + if (curr === me) { + p.status = FULFILLED + p.value = v + resolve(v) + complete() + } + } + const onRejected = (me: PromiseLike) => (e: AnyError) => { + if (curr === me) { + p.status = REJECTED + p.reason = e + reject(e) + complete() + } + } + promise.then(onFulfilled(promise), onRejected(promise)) + continuePromise = (nextPromise, nextAbort) => { + if (nextPromise) { + continuablePromiseMap.set(nextPromise, p) + curr = nextPromise + nextPromise.then(onFulfilled(nextPromise), onRejected(nextPromise)) + + // Only abort promises that aren't user-facing. When nextPromise is set, + // we can replace the current promise with the next one, so we don't + // see any abort-related errors. + abort() + abort = nextAbort + } + } + }) + p.status = PENDING + p[CONTINUE_PROMISE] = continuePromise! + continuablePromiseMap.set(promise, p) + } + return continuablePromiseMap.get(promise) as ContinuablePromise } const isPromiseLike = (x: unknown): x is PromiseLike => typeof (x as any)?.then === 'function' /** - * Immutable map from a dependency to the dependency's atom state - * when it was last read. - * We can skip recomputation of an atom by comparing the atom state - * of each dependency to that dependencies's current revision. + * State tracked for mounted atoms. An atom is considered "mounted" if it has a + * subscriber, or is a transitive dependency of another atom that has a + * subscriber. + * + * The mounted state of an atom is freed once it is no longer mounted. */ -type Dependencies = Map -type NextDependencies = Map +type Mounted = { + /** Set of listeners to notify when the atom value changes. */ + readonly l: Set<() => void> + /** Set of mounted atoms that the atom depends on. */ + readonly d: Set + /** Set of mounted atoms that depends on the atom. */ + readonly t: Set + /** Function to run when the atom is unmounted. */ + u?: OnUnmount +} /** - * Immutable atom state, + * Mutable atom state, * tracked for both mounted and unmounted atoms in a store. */ type AtomState = { - d: Dependencies -} & ({ e: AnyError } | { v: Value }) - -const isEqualAtomValue = ( - a: AtomState | undefined, - b: AtomState, -): a is AtomState => !!a && 'v' in a && 'v' in b && Object.is(a.v, b.v) - -const isEqualAtomError = ( - a: AtomState | undefined, - b: AtomState, -): a is AtomState => !!a && 'e' in a && 'e' in b && Object.is(a.e, b.e) - -const hasPromiseAtomValue = ( - a: AtomState | undefined, -): a is AtomState & { v: Value & Promise } => - !!a && 'v' in a && a.v instanceof Promise + /** + * Map of atoms that the atom depends on. + * The map value is the epoch number of the dependency. + */ + readonly d: Map + /** + * Set of atoms with pending promise that depend on the atom. + * + * This may cause memory leaks, but it's for the capability to continue promises + */ + readonly p: Set + /** The epoch number of the atom. */ + n: number + /** Object to store mounted state of the atom. */ + m?: Mounted // only available if the atom is mounted + /** Atom value */ + v?: Value + /** Atom error */ + e?: AnyError +} -const isEqualPromiseAtomValue = ( - a: AtomState & PromiseMeta>, - b: AtomState & PromiseMeta>, -) => 'v' in a && 'v' in b && a.v.orig && a.v.orig === b.v.orig +const isAtomStateInitialized = (atomState: AtomState) => + 'v' in atomState || 'e' in atomState const returnAtomValue = (atomState: AtomState): Value => { if ('e' in atomState) { throw atomState.e } - return atomState.v + if (import.meta.env?.MODE !== 'production' && !('v' in atomState)) { + throw new Error('[Bug] atom state is not initialized') + } + return atomState.v! } -type Listeners = Set<() => void> -type Dependents = Set +const getPendingContinuablePromise = (atomState: AtomState) => { + const value: unknown = atomState.v + if (isContinuablePromise(value) && value.status === PENDING) { + return value + } + return null +} -/** - * State tracked for mounted atoms. An atom is considered "mounted" if it has a - * subscriber, or is a transitive dependency of another atom that has a - * subscriber. - * - * The mounted state of an atom is freed once it is no longer mounted. - */ -type Mounted = { - /** The list of subscriber functions. */ - l: Listeners - /** Atoms that depend on *this* atom. Used to fan out invalidation. */ - t: Dependents - /** Function to run when the atom is unmounted. */ - u?: OnUnmount +const addPendingContinuablePromiseToDependency = ( + atom: AnyAtom, + promise: ContinuablePromise & { status: typeof PENDING }, + dependencyAtomState: AtomState, +) => { + if (!dependencyAtomState.p.has(atom)) { + dependencyAtomState.p.add(atom) + promise.then( + () => { + dependencyAtomState.p.delete(atom) + }, + () => { + dependencyAtomState.p.delete(atom) + }, + ) + } +} + +// +// Pending +// + +type Pending = readonly [ + dependents: Map>, + atomStates: Map, + functions: Set<() => void>, +] + +const createPending = (): Pending => [new Map(), new Map(), new Set()] + +const addPendingAtom = ( + pending: Pending, + atom: AnyAtom, + atomState: AtomState, +) => { + if (!pending[0].has(atom)) { + pending[0].set(atom, new Set()) + } + pending[1].set(atom, atomState) +} + +const addPendingDependent = ( + pending: Pending, + atom: AnyAtom, + dependent: AnyAtom, +) => { + const dependents = pending[0].get(atom) + if (dependents) { + dependents.add(dependent) + } } -type MountedAtoms = Set +const getPendingDependents = (pending: Pending, atom: AnyAtom) => + pending[0].get(atom) + +const addPendingFunction = (pending: Pending, fn: () => void) => { + pending[2].add(fn) +} + +const flushPending = (pending: Pending) => { + while (pending[1].size || pending[2].size) { + pending[0].clear() + const atomStates = new Set(pending[1].values()) + pending[1].clear() + const functions = new Set(pending[2]) + pending[2].clear() + atomStates.forEach((atomState) => atomState.m?.l.forEach((l) => l())) + functions.forEach((fn) => fn()) + } +} // for debugging purpose only -type DevListenerRev2 = ( - action: - | { type: 'write'; flushed: Set } - | { type: 'async-write'; flushed: Set } - | { type: 'sub'; flushed: Set } - | { type: 'unsub' } - | { type: 'restore'; flushed: Set }, -) => void -type DevStoreRev2 = { - dev_subscribe_store: (l: DevListenerRev2, rev: 2) => () => void - dev_get_mounted_atoms: () => IterableIterator - dev_get_atom_state: (a: AnyAtom) => AtomState | undefined - dev_get_mounted: (a: AnyAtom) => Mounted | undefined - dev_restore_atoms: (values: Iterable) => void +type DevStoreRev4 = { + dev4_get_internal_weak_map: () => WeakMap + dev4_get_mounted_atoms: () => Set + dev4_restore_atoms: (values: Iterable) => void } type PrdStore = { @@ -151,302 +259,151 @@ type PrdStore = { ) => Result sub: (atom: AnyAtom, listener: () => void) => () => void } -type Store = PrdStore & Partial +type Store = PrdStore | (PrdStore & DevStoreRev4) + +export type INTERNAL_DevStoreRev4 = DevStoreRev4 +export type INTERNAL_PrdStore = PrdStore -/** - * Create a new store. Each store is an independent, isolated universe of atom - * states. - * - * Jotai atoms are not themselves state containers. When you read or write an - * atom, that state is stored in a store. You can think of a Store like a - * multi-layered map from atoms to states, like this: - * - * ``` - * // Conceptually, a Store is a map from atoms to states. - * // The real type is a bit different. - * type Store = Map> - * ``` - * - * @returns A store. - */ export const createStore = (): Store => { const atomStateMap = new WeakMap() - const mountedMap = new WeakMap() - const pendingStack: Set[] = [] - const pendingMap = new WeakMap< - AnyAtom, - [prevAtomState: AtomState | undefined, dependents: Dependents] - >() - let devListenersRev2: Set - let mountedAtoms: MountedAtoms - if (import.meta.env?.MODE !== 'production') { - devListenersRev2 = new Set() - mountedAtoms = new Set() - } - - const getAtomState = (atom: Atom) => - atomStateMap.get(atom) as AtomState | undefined + // for debugging purpose only + let debugMountedAtoms: Set - const addPendingDependent = (atom: AnyAtom, atomState: AtomState) => { - atomState.d.forEach((_, a) => { - if (!pendingMap.has(a)) { - const aState = getAtomState(a) - pendingMap.set(a, [aState, new Set()]) - if (aState) { - addPendingDependent(a, aState) - } - } - pendingMap.get(a)![1].add(atom) - }) - } - - const setAtomState = ( - atom: Atom, - atomState: AtomState, - ): void => { - if (import.meta.env?.MODE !== 'production') { - Object.freeze(atomState) - } - const prevAtomState = getAtomState(atom) - atomStateMap.set(atom, atomState) - pendingStack[pendingStack.length - 1]?.add(atom) - if (!pendingMap.has(atom)) { - pendingMap.set(atom, [prevAtomState, new Set()]) - addPendingDependent(atom, atomState) - } - if (hasPromiseAtomValue(prevAtomState)) { - const next = - 'v' in atomState - ? atomState.v instanceof Promise - ? atomState.v - : Promise.resolve(atomState.v) - : Promise.reject(atomState.e) - if (prevAtomState.v !== next) { - cancelPromise(prevAtomState.v, next) - } - } - } - - const updateDependencies = ( - atom: Atom, - nextAtomState: AtomState, - nextDependencies: NextDependencies, - keepPreviousDependencies?: boolean, - ): void => { - const dependencies: Dependencies = new Map( - keepPreviousDependencies ? nextAtomState.d : null, - ) - let changed = false - nextDependencies.forEach((aState, a) => { - if (!aState && isSelfAtom(atom, a)) { - aState = nextAtomState - } - if (aState) { - dependencies.set(a, aState) - if (nextAtomState.d.get(a) !== aState) { - changed = true - } - } else if (import.meta.env?.MODE !== 'production') { - console.warn('[Bug] atom state not found') - } - }) - if (changed || nextAtomState.d.size !== dependencies.size) { - nextAtomState.d = dependencies - } + if (import.meta.env?.MODE !== 'production') { + debugMountedAtoms = new Set() } - const setAtomValue = ( - atom: Atom, - value: Value, - nextDependencies?: NextDependencies, - keepPreviousDependencies?: boolean, - ): AtomState => { - const prevAtomState = getAtomState(atom) - const nextAtomState: AtomState = { - d: prevAtomState?.d || new Map(), - v: value, - } - if (nextDependencies) { - updateDependencies( - atom, - nextAtomState, - nextDependencies, - keepPreviousDependencies, - ) - } - if ( - isEqualAtomValue(prevAtomState, nextAtomState) && - prevAtomState.d === nextAtomState.d - ) { - // bail out - return prevAtomState - } - if ( - hasPromiseAtomValue(prevAtomState) && - hasPromiseAtomValue(nextAtomState) && - isEqualPromiseAtomValue(prevAtomState, nextAtomState) - ) { - if (prevAtomState.d === nextAtomState.d) { - // bail out - return prevAtomState - } else { - // restore the wrapped promise - nextAtomState.v = prevAtomState.v - } + const getAtomState = (atom: Atom) => { + let atomState = atomStateMap.get(atom) as AtomState | undefined + if (!atomState) { + atomState = { d: new Map(), p: new Set(), n: 0 } + atomStateMap.set(atom, atomState) } - setAtomState(atom, nextAtomState) - return nextAtomState + return atomState } - const setAtomValueOrPromise = ( - atom: Atom, - valueOrPromise: Value, - nextDependencies?: NextDependencies, - abortPromise?: () => void, - ): AtomState => { + const setAtomStateValueOrPromise = ( + atom: AnyAtom, + atomState: AtomState, + valueOrPromise: unknown, + abortPromise = () => {}, + completePromise = () => {}, + ) => { + const hasPrevValue = 'v' in atomState + const prevValue = atomState.v + const pendingPromise = getPendingContinuablePromise(atomState) if (isPromiseLike(valueOrPromise)) { - let continuePromise: (next: Promise>) => void - const updatePromiseDependencies = () => { - const prevAtomState = getAtomState(atom) - if ( - !hasPromiseAtomValue(prevAtomState) || - prevAtomState.v !== promise - ) { - // not the latest promise - return + if (pendingPromise) { + if (pendingPromise !== valueOrPromise) { + pendingPromise[CONTINUE_PROMISE](valueOrPromise, abortPromise) + ++atomState.n } - // update dependencies, that could have changed - const nextAtomState = setAtomValue( - atom, - promise as Value, - nextDependencies, + } else { + const continuablePromise = createContinuablePromise( + valueOrPromise, + abortPromise, + completePromise, ) - if (mountedMap.has(atom) && prevAtomState.d !== nextAtomState.d) { - mountDependencies(atom, nextAtomState, prevAtomState.d) - } - } - const promise: Promise> & PromiseMeta> = - new Promise((resolve, reject) => { - let settled = false - valueOrPromise.then( - (v) => { - if (!settled) { - settled = true - resolvePromise(promise, v) - resolve(v as Awaited) - updatePromiseDependencies() - } - }, - (e) => { - if (!settled) { - settled = true - rejectPromise(promise, e) - reject(e) - updatePromiseDependencies() - } - }, - ) - continuePromise = (next) => { - if (!settled) { - settled = true - next.then( - (v) => resolvePromise(promise, v), - (e) => rejectPromise(promise, e), - ) - resolve(next) - } + if (continuablePromise.status === PENDING) { + for (const a of atomState.d.keys()) { + const aState = getAtomState(a) + addPendingContinuablePromiseToDependency( + atom, + continuablePromise, + aState, + ) } - }) - promise.orig = valueOrPromise as PromiseLike> - promise.status = 'pending' - registerCancelPromise(promise, (next) => { - if (next) { - continuePromise(next as Promise>) - - // Only abort promises that aren't user-facing. When next is set, - // we can replace the current promise with the next one, so we don't - // see any abort-related errors. - abortPromise?.() } - }) - return setAtomValue(atom, promise as Value, nextDependencies, true) + atomState.v = continuablePromise + delete atomState.e + } + } else { + if (pendingPromise) { + pendingPromise[CONTINUE_PROMISE]( + Promise.resolve(valueOrPromise), + abortPromise, + ) + } + atomState.v = valueOrPromise + delete atomState.e + } + if (!hasPrevValue || !Object.is(prevValue, atomState.v)) { + ++atomState.n } - return setAtomValue(atom, valueOrPromise, nextDependencies) } - - const setAtomError = ( + const addDependency = ( + pending: Pending | undefined, atom: Atom, - error: AnyError, - nextDependencies?: NextDependencies, - ): AtomState => { - const prevAtomState = getAtomState(atom) - const nextAtomState: AtomState = { - d: prevAtomState?.d || new Map(), - e: error, + a: AnyAtom, + aState: AtomState, + ) => { + if (import.meta.env?.MODE !== 'production' && a === atom) { + throw new Error('[Bug] atom cannot depend on itself') } - if (nextDependencies) { - updateDependencies(atom, nextAtomState, nextDependencies) + const atomState = getAtomState(atom) + atomState.d.set(a, aState.n) + const continuablePromise = getPendingContinuablePromise(atomState) + if (continuablePromise) { + addPendingContinuablePromiseToDependency(atom, continuablePromise, aState) } - if ( - isEqualAtomError(prevAtomState, nextAtomState) && - prevAtomState.d === nextAtomState.d - ) { - // bail out - return prevAtomState + aState.m?.t.add(atom) + if (pending) { + addPendingDependent(pending, a, atom) } - setAtomState(atom, nextAtomState) - return nextAtomState } const readAtomState = ( + pending: Pending | undefined, atom: Atom, force?: (a: AnyAtom) => boolean, ): AtomState => { // See if we can skip recomputing this atom. const atomState = getAtomState(atom) - if (!force?.(atom) && atomState) { + if (!force?.(atom) && isAtomStateInitialized(atomState)) { // If the atom is mounted, we can use the cache. // because it should have been updated by dependencies. - if (mountedMap.has(atom)) { + if (atomState.m) { return atomState } // Otherwise, check if the dependencies have changed. // If all dependencies haven't changed, we can use the cache. if ( - Array.from(atomState.d).every(([a, s]) => { - // we shouldn't use isSelfAtom. https://github.com/pmndrs/jotai/pull/2371 - if (a === atom) { - return true - } - const aState = readAtomState(a, force) - // Check if the atom state is unchanged, or - // check the atom value in case only dependencies are changed - return aState === s || isEqualAtomValue(aState, s) - }) + Array.from(atomState.d).every( + ([a, n]) => + // Recursively, read the atom state of the dependency, and + // check if the atom epoch number is unchanged + readAtomState(pending, a, force).n === n, + ) ) { return atomState } } // Compute a new state for this atom. - const nextDependencies: NextDependencies = new Map() + atomState.d.clear() let isSync = true const getter: Getter = (a: Atom) => { if (isSelfAtom(atom, a)) { const aState = getAtomState(a) - if (aState) { - nextDependencies.set(a, aState) - return returnAtomValue(aState) - } - if (hasInitialValue(a)) { - nextDependencies.set(a, undefined) - return a.init + if (!isAtomStateInitialized(aState)) { + if (hasInitialValue(a)) { + setAtomStateValueOrPromise(a, aState, a.init) + } else { + // NOTE invalid derived atoms can reach here + throw new Error('no atom init') + } } - // NOTE invalid derived atoms can reach here - throw new Error('no atom init') + return returnAtomValue(aState) } // a !== atom - const aState = readAtomState(a, force) - nextDependencies.set(a, aState) + const aState = readAtomState(pending, a, force) + if (isSync) { + addDependency(pending, atom, a, aState) + } else { + const pending = createPending() + addDependency(pending, atom, a, aState) + mountDependencies(pending, atom, atomState) + flushPending(pending) + } return returnAtomValue(aState) } let controller: AbortController | undefined @@ -480,23 +437,41 @@ export const createStore = (): Store => { } try { const valueOrPromise = atom.read(getter, options as never) - return setAtomValueOrPromise(atom, valueOrPromise, nextDependencies, () => - controller?.abort(), + setAtomStateValueOrPromise( + atom, + atomState, + valueOrPromise, + () => controller?.abort(), + () => { + if (atomState.m) { + const pending = createPending() + mountDependencies(pending, atom, atomState) + flushPending(pending) + } + }, ) + return atomState } catch (error) { - return setAtomError(atom, error, nextDependencies) + delete atomState.v + atomState.e = error + ++atomState.n + return atomState } finally { isSync = false } } const readAtom = (atom: Atom): Value => - returnAtomValue(readAtomState(atom)) - - const recomputeDependents = (atom: AnyAtom): void => { - const getDependents = (a: AnyAtom): Dependents => { - const dependents = new Set(mountedMap.get(a)?.t) - pendingMap.get(a)?.[1].forEach((dependent) => { + returnAtomValue(readAtomState(undefined, atom)) + + const recomputeDependents = (pending: Pending, atom: AnyAtom) => { + const getDependents = (a: AnyAtom): Set => { + const aState = getAtomState(a) + const dependents = new Set(aState.m?.t) + for (const atomWithPendingContinuablePromise of aState.p) { + dependents.add(atomWithPendingContinuablePromise) + } + getPendingDependents(pending, a)?.forEach((dependent) => { dependents.add(dependent) }) return dependents @@ -508,7 +483,7 @@ export const createStore = (): Store => { // Step 1: traverse the dependency graph to build the topsorted atom list // We don't bother to check for cycles, which simplifies the algorithm. - const topsortedAtoms = new Array() + const topsortedAtoms: AnyAtom[] = [] const markedAtoms = new Set() const visit = (n: AnyAtom) => { if (markedAtoms.has(n)) { @@ -526,32 +501,29 @@ export const createStore = (): Store => { // reverse order later. topsortedAtoms.push(n) } - // Visit the root atom. This is the only atom in the dependency graph // without incoming edges, which is one reason we can simplify the algorithm visit(atom) - // Step 2: use the topsorted atom list to recompute all affected atoms // Track what's changed, so that we can short circuit when possible const changedAtoms = new Set([atom]) const isMarked = (a: AnyAtom) => markedAtoms.has(a) for (let i = topsortedAtoms.length - 1; i >= 0; --i) { const a = topsortedAtoms[i]! - const prevAtomState = getAtomState(a) - if (!prevAtomState) { - continue - } + const aState = getAtomState(a) + const prevEpochNumber = aState.n let hasChangedDeps = false - for (const dep of prevAtomState.d.keys()) { + for (const dep of aState.d.keys()) { if (dep !== a && changedAtoms.has(dep)) { hasChangedDeps = true break } } if (hasChangedDeps) { - const nextAtomState = readAtomState(a, isMarked) - addPendingDependent(a, nextAtomState) - if (!isEqualAtomValue(prevAtomState, nextAtomState)) { + readAtomState(pending, a, isMarked) + mountDependencies(pending, a, aState) + if (prevEpochNumber !== aState.n) { + addPendingAtom(pending, a, aState) changedAtoms.add(a) } } @@ -560,40 +532,36 @@ export const createStore = (): Store => { } const writeAtomState = ( + pending: Pending, atom: WritableAtom, ...args: Args ): Result => { - const getter: Getter = (a: Atom) => returnAtomValue(readAtomState(a)) + const getter: Getter = (a: Atom) => + returnAtomValue(readAtomState(pending, a)) const setter: Setter = ( a: WritableAtom, ...args: As ) => { - const isSync = pendingStack.length > 0 - if (!isSync) { - pendingStack.push(new Set([a])) - } let r: R | undefined if (isSelfAtom(atom, a)) { if (!hasInitialValue(a)) { // NOTE technically possible but restricted as it may cause bugs throw new Error('atom not writable') } - const prevAtomState = getAtomState(a) - const nextAtomState = setAtomValueOrPromise(a, args[0] as V) - if (!isEqualAtomValue(prevAtomState, nextAtomState)) { - recomputeDependents(a) + const aState = getAtomState(a) + const hasPrevValue = 'v' in aState + const prevValue = aState.v + const v = args[0] as V + setAtomStateValueOrPromise(a, aState, v) + mountDependencies(pending, a, aState) + if (!hasPrevValue || !Object.is(prevValue, aState.v)) { + addPendingAtom(pending, a, aState) + recomputeDependents(pending, a) } } else { - r = writeAtomState(a as AnyWritableAtom, ...args) as R - } - if (!isSync) { - const flushed = flushPending(pendingStack.pop()!) - if (import.meta.env?.MODE !== 'production') { - devListenersRev2.forEach((l) => - l({ type: 'async-write', flushed: flushed! }), - ) - } + r = writeAtomState(pending, a as AnyWritableAtom, ...args) as R } + flushPending(pending) return r as R } const result = atom.write(getter, setter, ...args) @@ -604,238 +572,146 @@ export const createStore = (): Store => { atom: WritableAtom, ...args: Args ): Result => { - pendingStack.push(new Set([atom])) - const result = writeAtomState(atom, ...args) - const flushed = flushPending(pendingStack.pop()!) - if (import.meta.env?.MODE !== 'production') { - devListenersRev2.forEach((l) => l({ type: 'write', flushed: flushed! })) - } + const pending = createPending() + const result = writeAtomState(pending, atom, ...args) + flushPending(pending) return result } - const mountAtom = ( - atom: Atom, - initialDependent?: AnyAtom, - onMountQueue?: (() => void)[], - ): Mounted => { - const existingMount = mountedMap.get(atom) - if (existingMount) { - if (initialDependent) { - existingMount.t.add(initialDependent) - } - return existingMount - } - - const queue = onMountQueue || [] - // mount dependencies before mounting self - getAtomState(atom)?.d.forEach((_, a) => { - if (a !== atom) { - mountAtom(a, atom, queue) + const mountDependencies = ( + pending: Pending, + atom: AnyAtom, + atomState: AtomState, + ) => { + if (atomState.m && !getPendingContinuablePromise(atomState)) { + for (const a of atomState.d.keys()) { + if (!atomState.m.d.has(a)) { + const aMounted = mountAtom(pending, a) + aMounted.t.add(atom) + atomState.m.d.add(a) + } } - }) - // recompute atom state - readAtomState(atom) - // mount self - const mounted: Mounted = { - t: new Set(initialDependent && [initialDependent]), - l: new Set(), - } - mountedMap.set(atom, mounted) - if (import.meta.env?.MODE !== 'production') { - mountedAtoms.add(atom) - } - // onMount - if (isActuallyWritableAtom(atom) && atom.onMount) { - const { onMount } = atom - queue.push(() => { - const onUnmount = onMount((...args) => writeAtom(atom, ...args)) - if (onUnmount) { - mounted.u = onUnmount + for (const a of atomState.m.d || []) { + if (!atomState.d.has(a)) { + const aMounted = unmountAtom(pending, a) + aMounted?.t.delete(atom) + atomState.m.d.delete(a) } - }) - } - if (!onMountQueue) { - queue.forEach((f) => f()) + } } - return mounted } - // FIXME doesn't work with mutually dependent atoms - const canUnmountAtom = (atom: AnyAtom, mounted: Mounted) => - !mounted.l.size && - (!mounted.t.size || (mounted.t.size === 1 && mounted.t.has(atom))) - - const tryUnmountAtom = (atom: Atom, mounted: Mounted): void => { - if (!canUnmountAtom(atom, mounted)) { - return - } - // unmount self - const onUnmount = mounted.u - if (onUnmount) { - onUnmount() - } - mountedMap.delete(atom) - if (import.meta.env?.MODE !== 'production') { - mountedAtoms.delete(atom) - } - // unmount dependencies afterward + const mountAtom = (pending: Pending, atom: AnyAtom): Mounted => { const atomState = getAtomState(atom) - if (atomState) { - // cancel promise - if (hasPromiseAtomValue(atomState)) { - cancelPromise(atomState.v) + if (!atomState.m) { + // recompute atom state + readAtomState(pending, atom) + // mount dependencies first + for (const a of atomState.d.keys()) { + const aMounted = mountAtom(pending, a) + aMounted.t.add(atom) } - atomState.d.forEach((_, a) => { - if (a !== atom) { - const mountedDep = mountedMap.get(a) - if (mountedDep) { - mountedDep.t.delete(atom) - tryUnmountAtom(a, mountedDep) - } - } - }) - } else if (import.meta.env?.MODE !== 'production') { - console.warn('[Bug] could not find atom state to unmount', atom) - } - } - - const mountDependencies = ( - atom: Atom, - atomState: AtomState, - prevDependencies?: Dependencies, - ): void => { - const depSet = new Set(atomState.d.keys()) - const maybeUnmountAtomSet = new Set() - prevDependencies?.forEach((_, a) => { - if (depSet.has(a)) { - // not changed - depSet.delete(a) - return + // mount self + atomState.m = { + l: new Set(), + d: new Set(atomState.d.keys()), + t: new Set(), } - maybeUnmountAtomSet.add(a) - const mounted = mountedMap.get(a) - if (mounted) { - mounted.t.delete(atom) // delete from dependents + if (import.meta.env?.MODE !== 'production') { + debugMountedAtoms.add(atom) } - }) - depSet.forEach((a) => { - mountAtom(a, atom) - }) - maybeUnmountAtomSet.forEach((a) => { - const mounted = mountedMap.get(a) - if (mounted) { - tryUnmountAtom(a, mounted) + if (isActuallyWritableAtom(atom) && atom.onMount) { + const mounted = atomState.m + const { onMount } = atom + addPendingFunction(pending, () => { + const onUnmount = onMount((...args) => + writeAtomState(pending, atom, ...args), + ) + if (onUnmount) { + mounted.u = onUnmount + } + }) } - }) + } + return atomState.m } - const flushPending = ( - pendingAtoms: AnyAtom[] | Set, - ): void | Set => { - let flushed: Set - if (import.meta.env?.MODE !== 'production') { - flushed = new Set() - } - const pending: [AnyAtom, AtomState | undefined][] = [] - const collectPending = (pendingAtom: AnyAtom) => { - if (!pendingMap.has(pendingAtom)) { - return + const unmountAtom = ( + pending: Pending, + atom: AnyAtom, + ): Mounted | undefined => { + const atomState = getAtomState(atom) + if ( + atomState.m && + !atomState.m.l.size && + !Array.from(atomState.m.t).some((a) => getAtomState(a).m) + ) { + // unmount self + const onUnmount = atomState.m.u + if (onUnmount) { + addPendingFunction(pending, onUnmount) } - const [prevAtomState, dependents] = pendingMap.get(pendingAtom)! - pendingMap.delete(pendingAtom) - pending.push([pendingAtom, prevAtomState]) - dependents.forEach(collectPending) - // FIXME might be better if we can avoid collecting from dependencies - getAtomState(pendingAtom)?.d.forEach((_, a) => collectPending(a)) - } - pendingAtoms.forEach(collectPending) - pending.forEach(([atom, prevAtomState]) => { - const atomState = getAtomState(atom) - if (!atomState) { - if (import.meta.env?.MODE !== 'production') { - console.warn('[Bug] no atom state to flush') - } - return + delete atomState.m + if (import.meta.env?.MODE !== 'production') { + debugMountedAtoms.delete(atom) } - if (atomState !== prevAtomState) { - const mounted = mountedMap.get(atom) - if (mounted && atomState.d !== prevAtomState?.d) { - mountDependencies(atom, atomState, prevAtomState?.d) - } - if ( - mounted && - !( - // TODO This seems pretty hacky. Hope to fix it. - // Maybe we could `mountDependencies` in `setAtomState`? - ( - !hasPromiseAtomValue(prevAtomState) && - (isEqualAtomValue(prevAtomState, atomState) || - isEqualAtomError(prevAtomState, atomState)) - ) - ) - ) { - mounted.l.forEach((listener) => listener()) - if (import.meta.env?.MODE !== 'production') { - flushed.add(atom) - } - } + // unmount dependencies + for (const a of atomState.d.keys()) { + const aMounted = unmountAtom(pending, a) + aMounted?.t.delete(atom) } - }) - if (import.meta.env?.MODE !== 'production') { - // @ts-expect-error Variable 'flushed' is used before being assigned. - return flushed + // abort pending promise + const pendingPromise = getPendingContinuablePromise(atomState) + if (pendingPromise) { + // FIXME using `undefined` is kind of a hack. + pendingPromise[CONTINUE_PROMISE](undefined, () => {}) + } + return undefined } + return atomState.m } const subscribeAtom = (atom: AnyAtom, listener: () => void) => { - const mounted = mountAtom(atom) - const flushed = flushPending([atom]) + const pending = createPending() + const mounted = mountAtom(pending, atom) + flushPending(pending) const listeners = mounted.l listeners.add(listener) - if (import.meta.env?.MODE !== 'production') { - devListenersRev2.forEach((l) => - l({ type: 'sub', flushed: flushed as Set }), - ) - } return () => { listeners.delete(listener) - tryUnmountAtom(atom, mounted) - if (import.meta.env?.MODE !== 'production') { - // devtools uses this to detect if it _can_ unmount or not - devListenersRev2.forEach((l) => l({ type: 'unsub' })) - } + const pending = createPending() + unmountAtom(pending, atom) + flushPending(pending) } } if (import.meta.env?.MODE !== 'production') { - return { + const store: Store = { get: readAtom, set: writeAtom, sub: subscribeAtom, // store dev methods (these are tentative and subject to change without notice) - dev_subscribe_store: (l) => { - devListenersRev2.add(l) - return () => { - devListenersRev2.delete(l) - } - }, - dev_get_mounted_atoms: () => mountedAtoms.values(), - dev_get_atom_state: (a) => atomStateMap.get(a), - dev_get_mounted: (a) => mountedMap.get(a), - dev_restore_atoms: (values) => { - pendingStack.push(new Set()) - for (const [atom, valueOrPromise] of values) { + dev4_get_internal_weak_map: () => atomStateMap, + dev4_get_mounted_atoms: () => debugMountedAtoms, + dev4_restore_atoms: (values) => { + const pending = createPending() + for (const [atom, value] of values) { if (hasInitialValue(atom)) { - setAtomValueOrPromise(atom, valueOrPromise) - recomputeDependents(atom) + const aState = getAtomState(atom) + const hasPrevValue = 'v' in aState + const prevValue = aState.v + setAtomStateValueOrPromise(atom, aState, value) + mountDependencies(pending, atom, aState) + if (!hasPrevValue || !Object.is(prevValue, aState.v)) { + addPendingAtom(pending, atom, aState) + recomputeDependents(pending, atom) + } } } - const flushed = flushPending(pendingStack.pop()!) - devListenersRev2.forEach((l) => - l({ type: 'restore', flushed: flushed! }), - ) + flushPending(pending) }, } + return store } return { get: readAtom, diff --git a/src/vanilla/store2.ts b/src/vanilla/store2.ts deleted file mode 100644 index f8d317d07d..0000000000 --- a/src/vanilla/store2.ts +++ /dev/null @@ -1,738 +0,0 @@ -import type { Atom, WritableAtom } from './atom.ts' - -type AnyValue = unknown -type AnyError = unknown -type AnyAtom = Atom -type AnyWritableAtom = WritableAtom -type OnUnmount = () => void -type Getter = Parameters[0] -type Setter = Parameters[1] - -const isSelfAtom = (atom: AnyAtom, a: AnyAtom): boolean => - atom.unstable_is ? atom.unstable_is(a) : a === atom - -const hasInitialValue = >( - atom: T, -): atom is T & (T extends Atom ? { init: Value } : never) => - 'init' in atom - -const isActuallyWritableAtom = (atom: AnyAtom): atom is AnyWritableAtom => - !!(atom as AnyWritableAtom).write - -// -// Continuable Promise -// - -const CONTINUE_PROMISE = Symbol( - import.meta.env?.MODE !== 'production' ? 'CONTINUE_PROMISE' : '', -) - -const PENDING = 'pending' -const FULFILLED = 'fulfilled' -const REJECTED = 'rejected' - -type ContinuePromise = ( - nextPromise: PromiseLike | undefined, - nextAbort: () => void, -) => void - -type ContinuablePromise = Promise & - ( - | { status: typeof PENDING } - | { status: typeof FULFILLED; value?: T } - | { status: typeof REJECTED; reason?: AnyError } - ) & { - [CONTINUE_PROMISE]: ContinuePromise - } - -const isContinuablePromise = ( - promise: unknown, -): promise is ContinuablePromise => - typeof promise === 'object' && promise !== null && CONTINUE_PROMISE in promise - -const continuablePromiseMap: WeakMap< - PromiseLike, - ContinuablePromise -> = new WeakMap() - -/** - * Create a continuable promise from a regular promise. - */ -const createContinuablePromise = ( - promise: PromiseLike, - abort: () => void, - complete: () => void, -): ContinuablePromise => { - if (!continuablePromiseMap.has(promise)) { - let continuePromise: ContinuePromise - const p: any = new Promise((resolve, reject) => { - let curr = promise - const onFulfilled = (me: PromiseLike) => (v: T) => { - if (curr === me) { - p.status = FULFILLED - p.value = v - resolve(v) - complete() - } - } - const onRejected = (me: PromiseLike) => (e: AnyError) => { - if (curr === me) { - p.status = REJECTED - p.reason = e - reject(e) - complete() - } - } - promise.then(onFulfilled(promise), onRejected(promise)) - continuePromise = (nextPromise, nextAbort) => { - if (nextPromise) { - continuablePromiseMap.set(nextPromise, p) - curr = nextPromise - nextPromise.then(onFulfilled(nextPromise), onRejected(nextPromise)) - - // Only abort promises that aren't user-facing. When nextPromise is set, - // we can replace the current promise with the next one, so we don't - // see any abort-related errors. - abort() - abort = nextAbort - } - } - }) - p.status = PENDING - p[CONTINUE_PROMISE] = continuePromise! - continuablePromiseMap.set(promise, p) - } - return continuablePromiseMap.get(promise) as ContinuablePromise -} - -const isPromiseLike = (x: unknown): x is PromiseLike => - typeof (x as any)?.then === 'function' - -/** - * State tracked for mounted atoms. An atom is considered "mounted" if it has a - * subscriber, or is a transitive dependency of another atom that has a - * subscriber. - * - * The mounted state of an atom is freed once it is no longer mounted. - */ -type Mounted = { - /** Set of listeners to notify when the atom value changes. */ - readonly l: Set<() => void> - /** Set of mounted atoms that the atom depends on. */ - readonly d: Set - /** Set of mounted atoms that depends on the atom. */ - readonly t: Set - /** Function to run when the atom is unmounted. */ - u?: OnUnmount -} - -/** - * Mutable atom state, - * tracked for both mounted and unmounted atoms in a store. - */ -type AtomState = { - /** - * Map of atoms that the atom depends on. - * The map value is the epoch number of the dependency. - */ - readonly d: Map - /** - * Set of atoms with pending promise that depend on the atom. - * - * This may cause memory leaks, but it's for the capability to continue promises - */ - readonly p: Set - /** The epoch number of the atom. */ - n: number - /** Object to store mounted state of the atom. */ - m?: Mounted // only available if the atom is mounted - /** Atom value */ - v?: Value - /** Atom error */ - e?: AnyError -} - -const isAtomStateInitialized = (atomState: AtomState) => - 'v' in atomState || 'e' in atomState - -const returnAtomValue = (atomState: AtomState): Value => { - if ('e' in atomState) { - throw atomState.e - } - if (import.meta.env?.MODE !== 'production' && !('v' in atomState)) { - throw new Error('[Bug] atom state is not initialized') - } - return atomState.v! -} - -const getPendingContinuablePromise = (atomState: AtomState) => { - const value: unknown = atomState.v - if (isContinuablePromise(value) && value.status === PENDING) { - return value - } - return null -} - -const addPendingContinuablePromiseToDependency = ( - atom: AnyAtom, - promise: ContinuablePromise & { status: typeof PENDING }, - dependencyAtomState: AtomState, -) => { - if (!dependencyAtomState.p.has(atom)) { - dependencyAtomState.p.add(atom) - promise.then( - () => { - dependencyAtomState.p.delete(atom) - }, - () => { - dependencyAtomState.p.delete(atom) - }, - ) - } -} - -// -// Pending -// - -type Pending = readonly [ - dependents: Map>, - atomStates: Map, - functions: Set<() => void>, -] - -const createPending = (): Pending => [new Map(), new Map(), new Set()] - -const addPendingAtom = ( - pending: Pending, - atom: AnyAtom, - atomState: AtomState, -) => { - if (!pending[0].has(atom)) { - pending[0].set(atom, new Set()) - } - pending[1].set(atom, atomState) -} - -const addPendingDependent = ( - pending: Pending, - atom: AnyAtom, - dependent: AnyAtom, -) => { - const dependents = pending[0].get(atom) - if (dependents) { - dependents.add(dependent) - } -} - -const getPendingDependents = (pending: Pending, atom: AnyAtom) => - pending[0].get(atom) - -const addPendingFunction = (pending: Pending, fn: () => void) => { - pending[2].add(fn) -} - -const flushPending = (pending: Pending) => { - while (pending[1].size || pending[2].size) { - pending[0].clear() - const atomStates = new Set(pending[1].values()) - pending[1].clear() - const functions = new Set(pending[2]) - pending[2].clear() - atomStates.forEach((atomState) => atomState.m?.l.forEach((l) => l())) - functions.forEach((fn) => fn()) - } -} - -// for debugging purpose only -type DevStoreRev4 = { - dev4_get_internal_weak_map: () => WeakMap - dev4_get_mounted_atoms: () => Set - dev4_restore_atoms: (values: Iterable) => void -} - -type PrdStore = { - get: (atom: Atom) => Value - set: ( - atom: WritableAtom, - ...args: Args - ) => Result - sub: (atom: AnyAtom, listener: () => void) => () => void -} -type Store = PrdStore | (PrdStore & DevStoreRev4) - -export type INTERNAL_DevStoreRev4 = DevStoreRev4 -export type INTERNAL_PrdStore = PrdStore - -export const createStore = (): Store => { - const atomStateMap = new WeakMap() - // for debugging purpose only - let debugMountedAtoms: Set - - if (import.meta.env?.MODE !== 'production') { - debugMountedAtoms = new Set() - } - - const getAtomState = (atom: Atom) => { - let atomState = atomStateMap.get(atom) as AtomState | undefined - if (!atomState) { - atomState = { d: new Map(), p: new Set(), n: 0 } - atomStateMap.set(atom, atomState) - } - return atomState - } - - const setAtomStateValueOrPromise = ( - atom: AnyAtom, - atomState: AtomState, - valueOrPromise: unknown, - abortPromise = () => {}, - completePromise = () => {}, - ) => { - const hasPrevValue = 'v' in atomState - const prevValue = atomState.v - const pendingPromise = getPendingContinuablePromise(atomState) - if (isPromiseLike(valueOrPromise)) { - if (pendingPromise) { - if (pendingPromise !== valueOrPromise) { - pendingPromise[CONTINUE_PROMISE](valueOrPromise, abortPromise) - ++atomState.n - } - } else { - const continuablePromise = createContinuablePromise( - valueOrPromise, - abortPromise, - completePromise, - ) - if (continuablePromise.status === PENDING) { - for (const a of atomState.d.keys()) { - const aState = getAtomState(a) - addPendingContinuablePromiseToDependency( - atom, - continuablePromise, - aState, - ) - } - } - atomState.v = continuablePromise - delete atomState.e - } - } else { - if (pendingPromise) { - pendingPromise[CONTINUE_PROMISE]( - Promise.resolve(valueOrPromise), - abortPromise, - ) - } - atomState.v = valueOrPromise - delete atomState.e - } - if (!hasPrevValue || !Object.is(prevValue, atomState.v)) { - ++atomState.n - } - } - const addDependency = ( - pending: Pending | undefined, - atom: Atom, - a: AnyAtom, - aState: AtomState, - ) => { - if (import.meta.env?.MODE !== 'production' && a === atom) { - throw new Error('[Bug] atom cannot depend on itself') - } - const atomState = getAtomState(atom) - atomState.d.set(a, aState.n) - const continuablePromise = getPendingContinuablePromise(atomState) - if (continuablePromise) { - addPendingContinuablePromiseToDependency(atom, continuablePromise, aState) - } - aState.m?.t.add(atom) - if (pending) { - addPendingDependent(pending, a, atom) - } - } - - const readAtomState = ( - pending: Pending | undefined, - atom: Atom, - force?: (a: AnyAtom) => boolean, - ): AtomState => { - // See if we can skip recomputing this atom. - const atomState = getAtomState(atom) - if (!force?.(atom) && isAtomStateInitialized(atomState)) { - // If the atom is mounted, we can use the cache. - // because it should have been updated by dependencies. - if (atomState.m) { - return atomState - } - // Otherwise, check if the dependencies have changed. - // If all dependencies haven't changed, we can use the cache. - if ( - Array.from(atomState.d).every( - ([a, n]) => - // Recursively, read the atom state of the dependency, and - // check if the atom epoch number is unchanged - readAtomState(pending, a, force).n === n, - ) - ) { - return atomState - } - } - // Compute a new state for this atom. - atomState.d.clear() - let isSync = true - const getter: Getter = (a: Atom) => { - if (isSelfAtom(atom, a)) { - const aState = getAtomState(a) - if (!isAtomStateInitialized(aState)) { - if (hasInitialValue(a)) { - setAtomStateValueOrPromise(a, aState, a.init) - } else { - // NOTE invalid derived atoms can reach here - throw new Error('no atom init') - } - } - return returnAtomValue(aState) - } - // a !== atom - const aState = readAtomState(pending, a, force) - if (isSync) { - addDependency(pending, atom, a, aState) - } else { - const pending = createPending() - addDependency(pending, atom, a, aState) - mountDependencies(pending, atom, atomState) - flushPending(pending) - } - return returnAtomValue(aState) - } - let controller: AbortController | undefined - let setSelf: ((...args: unknown[]) => unknown) | undefined - const options = { - get signal() { - if (!controller) { - controller = new AbortController() - } - return controller.signal - }, - get setSelf() { - if ( - import.meta.env?.MODE !== 'production' && - !isActuallyWritableAtom(atom) - ) { - console.warn('setSelf function cannot be used with read-only atom') - } - if (!setSelf && isActuallyWritableAtom(atom)) { - setSelf = (...args) => { - if (import.meta.env?.MODE !== 'production' && isSync) { - console.warn('setSelf function cannot be called in sync') - } - if (!isSync) { - return writeAtom(atom, ...args) - } - } - } - return setSelf - }, - } - try { - const valueOrPromise = atom.read(getter, options as never) - setAtomStateValueOrPromise( - atom, - atomState, - valueOrPromise, - () => controller?.abort(), - () => { - if (atomState.m) { - const pending = createPending() - mountDependencies(pending, atom, atomState) - flushPending(pending) - } - }, - ) - return atomState - } catch (error) { - delete atomState.v - atomState.e = error - ++atomState.n - return atomState - } finally { - isSync = false - } - } - - const readAtom = (atom: Atom): Value => - returnAtomValue(readAtomState(undefined, atom)) - - const recomputeDependents = (pending: Pending, atom: AnyAtom) => { - const getDependents = (a: AnyAtom): Set => { - const aState = getAtomState(a) - const dependents = new Set(aState.m?.t) - for (const atomWithPendingContinuablePromise of aState.p) { - dependents.add(atomWithPendingContinuablePromise) - } - getPendingDependents(pending, a)?.forEach((dependent) => { - dependents.add(dependent) - }) - return dependents - } - - // This is a topological sort via depth-first search, slightly modified from - // what's described here for simplicity and performance reasons: - // https://en.wikipedia.org/wiki/Topological_sorting#Depth-first_search - - // Step 1: traverse the dependency graph to build the topsorted atom list - // We don't bother to check for cycles, which simplifies the algorithm. - const topsortedAtoms: AnyAtom[] = [] - const markedAtoms = new Set() - const visit = (n: AnyAtom) => { - if (markedAtoms.has(n)) { - return - } - markedAtoms.add(n) - for (const m of getDependents(n)) { - // we shouldn't use isSelfAtom here. - if (n !== m) { - visit(m) - } - } - // The algorithm calls for pushing onto the front of the list. For - // performance, we will simply push onto the end, and then will iterate in - // reverse order later. - topsortedAtoms.push(n) - } - // Visit the root atom. This is the only atom in the dependency graph - // without incoming edges, which is one reason we can simplify the algorithm - visit(atom) - // Step 2: use the topsorted atom list to recompute all affected atoms - // Track what's changed, so that we can short circuit when possible - const changedAtoms = new Set([atom]) - const isMarked = (a: AnyAtom) => markedAtoms.has(a) - for (let i = topsortedAtoms.length - 1; i >= 0; --i) { - const a = topsortedAtoms[i]! - const aState = getAtomState(a) - const prevEpochNumber = aState.n - let hasChangedDeps = false - for (const dep of aState.d.keys()) { - if (dep !== a && changedAtoms.has(dep)) { - hasChangedDeps = true - break - } - } - if (hasChangedDeps) { - readAtomState(pending, a, isMarked) - mountDependencies(pending, a, aState) - if (prevEpochNumber !== aState.n) { - addPendingAtom(pending, a, aState) - changedAtoms.add(a) - } - } - markedAtoms.delete(a) - } - } - - const writeAtomState = ( - pending: Pending, - atom: WritableAtom, - ...args: Args - ): Result => { - const getter: Getter = (a: Atom) => - returnAtomValue(readAtomState(pending, a)) - const setter: Setter = ( - a: WritableAtom, - ...args: As - ) => { - let r: R | undefined - if (isSelfAtom(atom, a)) { - if (!hasInitialValue(a)) { - // NOTE technically possible but restricted as it may cause bugs - throw new Error('atom not writable') - } - const aState = getAtomState(a) - const hasPrevValue = 'v' in aState - const prevValue = aState.v - const v = args[0] as V - setAtomStateValueOrPromise(a, aState, v) - mountDependencies(pending, a, aState) - if (!hasPrevValue || !Object.is(prevValue, aState.v)) { - addPendingAtom(pending, a, aState) - recomputeDependents(pending, a) - } - } else { - r = writeAtomState(pending, a as AnyWritableAtom, ...args) as R - } - flushPending(pending) - return r as R - } - const result = atom.write(getter, setter, ...args) - return result - } - - const writeAtom = ( - atom: WritableAtom, - ...args: Args - ): Result => { - const pending = createPending() - const result = writeAtomState(pending, atom, ...args) - flushPending(pending) - return result - } - - const mountDependencies = ( - pending: Pending, - atom: AnyAtom, - atomState: AtomState, - ) => { - if (atomState.m && !getPendingContinuablePromise(atomState)) { - for (const a of atomState.d.keys()) { - if (!atomState.m.d.has(a)) { - const aMounted = mountAtom(pending, a) - aMounted.t.add(atom) - atomState.m.d.add(a) - } - } - for (const a of atomState.m.d || []) { - if (!atomState.d.has(a)) { - const aMounted = unmountAtom(pending, a) - aMounted?.t.delete(atom) - atomState.m.d.delete(a) - } - } - } - } - - const mountAtom = (pending: Pending, atom: AnyAtom): Mounted => { - const atomState = getAtomState(atom) - if (!atomState.m) { - // recompute atom state - readAtomState(pending, atom) - // mount dependencies first - for (const a of atomState.d.keys()) { - const aMounted = mountAtom(pending, a) - aMounted.t.add(atom) - } - // mount self - atomState.m = { - l: new Set(), - d: new Set(atomState.d.keys()), - t: new Set(), - } - if (import.meta.env?.MODE !== 'production') { - debugMountedAtoms.add(atom) - } - if (isActuallyWritableAtom(atom) && atom.onMount) { - const mounted = atomState.m - const { onMount } = atom - addPendingFunction(pending, () => { - const onUnmount = onMount((...args) => - writeAtomState(pending, atom, ...args), - ) - if (onUnmount) { - mounted.u = onUnmount - } - }) - } - } - return atomState.m - } - - const unmountAtom = ( - pending: Pending, - atom: AnyAtom, - ): Mounted | undefined => { - const atomState = getAtomState(atom) - if ( - atomState.m && - !atomState.m.l.size && - !Array.from(atomState.m.t).some((a) => getAtomState(a).m) - ) { - // unmount self - const onUnmount = atomState.m.u - if (onUnmount) { - addPendingFunction(pending, onUnmount) - } - delete atomState.m - if (import.meta.env?.MODE !== 'production') { - debugMountedAtoms.delete(atom) - } - // unmount dependencies - for (const a of atomState.d.keys()) { - const aMounted = unmountAtom(pending, a) - aMounted?.t.delete(atom) - } - // abort pending promise - const pendingPromise = getPendingContinuablePromise(atomState) - if (pendingPromise) { - // FIXME using `undefined` is kind of a hack. - pendingPromise[CONTINUE_PROMISE](undefined, () => {}) - } - return undefined - } - return atomState.m - } - - const subscribeAtom = (atom: AnyAtom, listener: () => void) => { - const pending = createPending() - const mounted = mountAtom(pending, atom) - flushPending(pending) - const listeners = mounted.l - listeners.add(listener) - return () => { - listeners.delete(listener) - const pending = createPending() - unmountAtom(pending, atom) - flushPending(pending) - } - } - - if (import.meta.env?.MODE !== 'production') { - const store: Store = { - get: readAtom, - set: writeAtom, - sub: subscribeAtom, - // store dev methods (these are tentative and subject to change without notice) - dev4_get_internal_weak_map: () => atomStateMap, - dev4_get_mounted_atoms: () => debugMountedAtoms, - dev4_restore_atoms: (values) => { - const pending = createPending() - for (const [atom, value] of values) { - if (hasInitialValue(atom)) { - const aState = getAtomState(atom) - const hasPrevValue = 'v' in aState - const prevValue = aState.v - setAtomStateValueOrPromise(atom, aState, value) - mountDependencies(pending, atom, aState) - if (!hasPrevValue || !Object.is(prevValue, aState.v)) { - addPendingAtom(pending, atom, aState) - recomputeDependents(pending, atom) - } - } - } - flushPending(pending) - }, - } - return store - } - return { - get: readAtom, - set: writeAtom, - sub: subscribeAtom, - } -} - -let defaultStore: Store | undefined - -export const getDefaultStore = (): Store => { - if (!defaultStore) { - defaultStore = createStore() - if (import.meta.env?.MODE !== 'production') { - ;(globalThis as any).__JOTAI_DEFAULT_STORE__ ||= defaultStore - if ((globalThis as any).__JOTAI_DEFAULT_STORE__ !== defaultStore) { - console.warn( - 'Detected multiple Jotai instances. It may cause unexpected behavior with the default store. https://github.com/pmndrs/jotai/discussions/2044', - ) - } - } - } - return defaultStore -} diff --git a/tests/vanilla/dependency.test.tsx b/tests/vanilla/dependency.test.tsx index 0529639f0c..5de4a9927e 100644 --- a/tests/vanilla/dependency.test.tsx +++ b/tests/vanilla/dependency.test.tsx @@ -223,46 +223,43 @@ it('settles never resolving async derivations with deps picked up sync', async ( expect(sub).toBe(1) }) -it.skipIf(!import.meta.env?.USE_STORE2)( - 'settles never resolving async derivations with deps picked up async', - async () => { - const resolve: ((value: number) => void)[] = [] +it('settles never resolving async derivations with deps picked up async', async () => { + const resolve: ((value: number) => void)[] = [] - const syncAtom = atom({ - promise: new Promise((r) => resolve.push(r)), - }) + const syncAtom = atom({ + promise: new Promise((r) => resolve.push(r)), + }) - const asyncAtom = atom(async (get) => { - // we want to pick up `syncAtom` as an async dep - await Promise.resolve() + const asyncAtom = atom(async (get) => { + // we want to pick up `syncAtom` as an async dep + await Promise.resolve() - return await get(syncAtom).promise - }) + return await get(syncAtom).promise + }) - const store = createStore() + const store = createStore() - let sub = 0 - const values: unknown[] = [] - store.get(asyncAtom).then((value) => values.push(value)) + let sub = 0 + const values: unknown[] = [] + store.get(asyncAtom).then((value) => values.push(value)) - store.sub(asyncAtom, () => { - sub++ - store.get(asyncAtom).then((value) => values.push(value)) - }) + store.sub(asyncAtom, () => { + sub++ + store.get(asyncAtom).then((value) => values.push(value)) + }) - await new Promise((r) => setTimeout(r)) + await new Promise((r) => setTimeout(r)) - store.set(syncAtom, { - promise: new Promise((r) => resolve.push(r)), - }) + store.set(syncAtom, { + promise: new Promise((r) => resolve.push(r)), + }) - await new Promise((r) => setTimeout(r)) + await new Promise((r) => setTimeout(r)) - resolve[1]?.(1) + resolve[1]?.(1) - await new Promise((r) => setTimeout(r)) + await new Promise((r) => setTimeout(r)) - expect(values).toEqual([1, 1]) - expect(sub).toBe(1) - }, -) + expect(values).toEqual([1, 1]) + expect(sub).toBe(1) +}) diff --git a/tests/vanilla/memoryleaks.test.ts b/tests/vanilla/memoryleaks.test.ts index 97fb799f42..b1ea7821b2 100644 --- a/tests/vanilla/memoryleaks.test.ts +++ b/tests/vanilla/memoryleaks.test.ts @@ -26,32 +26,26 @@ describe('test memory leaks (get & set only)', () => { expect(await detector2.isLeaking()).toBe(false) }) - it.skipIf(!import.meta.env?.USE_STORE2)( - 'should not hold onto dependent atoms that are not mounted', - async () => { - const store = createStore() - const objAtom = atom({}) - let depAtom: Atom | undefined = atom((get) => get(objAtom)) - const detector = new LeakDetector(depAtom) - store.get(depAtom) - depAtom = undefined - await expect(detector.isLeaking()).resolves.toBe(false) - }, - ) + it('should not hold onto dependent atoms that are not mounted', async () => { + const store = createStore() + const objAtom = atom({}) + let depAtom: Atom | undefined = atom((get) => get(objAtom)) + const detector = new LeakDetector(depAtom) + store.get(depAtom) + depAtom = undefined + await expect(detector.isLeaking()).resolves.toBe(false) + }) - it.skipIf(!import.meta.env?.USE_STORE2)( - 'with a long-lived base atom', - async () => { - const store = createStore() - const objAtom = atom({}) - let derivedAtom: Atom | undefined = atom((get) => ({ - obj: get(objAtom), - })) - const detector = new LeakDetector(store.get(derivedAtom)) - derivedAtom = undefined - expect(await detector.isLeaking()).toBe(false) - }, - ) + it('with a long-lived base atom', async () => { + const store = createStore() + const objAtom = atom({}) + let derivedAtom: Atom | undefined = atom((get) => ({ + obj: get(objAtom), + })) + const detector = new LeakDetector(store.get(derivedAtom)) + derivedAtom = undefined + expect(await detector.isLeaking()).toBe(false) + }) }) describe('test memory leaks (with subscribe)', () => { diff --git a/tests/vanilla/store.test.tsx b/tests/vanilla/store.test.tsx index b328153929..652a551ca4 100644 --- a/tests/vanilla/store.test.tsx +++ b/tests/vanilla/store.test.tsx @@ -42,7 +42,9 @@ it('should unmount with store.get', async () => { const unsub = store.sub(countAtom, callback) store.get(countAtom) unsub() - const result = Array.from(store.dev_get_mounted_atoms?.() ?? []) + const result = Array.from( + 'dev4_get_mounted_atoms' in store ? store.dev4_get_mounted_atoms() : [], + ) expect(result).toEqual([]) }) @@ -54,7 +56,9 @@ it('should unmount dependencies with store.get', async () => { const unsub = store.sub(derivedAtom, callback) store.get(derivedAtom) unsub() - const result = Array.from(store.dev_get_mounted_atoms?.() ?? []) + const result = Array.from( + 'dev4_restore_atoms' in store ? store.dev4_get_mounted_atoms() : [], + ) expect(result).toEqual([]) }) diff --git a/tests/vanilla/storedev.test.tsx b/tests/vanilla/storedev.test.tsx index 5abfa4c497..1771fafc28 100644 --- a/tests/vanilla/storedev.test.tsx +++ b/tests/vanilla/storedev.test.tsx @@ -1,131 +1,11 @@ import { describe, expect, it, vi } from 'vitest' import { atom, createStore } from 'jotai/vanilla' -import { INTERNAL_DevStoreRev4, INTERNAL_PrdStore } from 'jotai/vanilla/store2' +import type { + INTERNAL_DevStoreRev4, + INTERNAL_PrdStore, +} from 'jotai/vanilla/store' -const IS_DEV_STORE = 'dev_subscribe_store' in createStore() -const IS_DEV_STORE2 = 'dev4_get_internal_weak_map' in createStore() - -describe.skipIf(!IS_DEV_STORE)('[DEV-ONLY] dev-only methods rev2', () => { - it('should return the values of all mounted atoms', () => { - const store = createStore() - const countAtom = atom(0) - countAtom.debugLabel = 'countAtom' - const derivedAtom = atom((get) => get(countAtom) * 0) - const unsub = store.sub(derivedAtom, vi.fn()) - store.set(countAtom, 1) - - const result = store.dev_get_mounted_atoms?.() || [] - expect( - Array.from(result).sort( - (a, b) => Object.keys(a).length - Object.keys(b).length, - ), - ).toStrictEqual([ - { toString: expect.any(Function), read: expect.any(Function) }, - { - toString: expect.any(Function), - init: 0, - read: expect.any(Function), - write: expect.any(Function), - debugLabel: 'countAtom', - }, - ]) - unsub() - }) - - it('should get atom state of a given atom', () => { - const store = createStore() - const countAtom = atom(0) - const unsub = store.sub(countAtom, vi.fn()) - store.set(countAtom, 1) - const result = store.dev_get_atom_state?.(countAtom) - expect(result).toHaveProperty('v', 1) - unsub() - }) - - it('should get mounted atom from mounted map', () => { - const store = createStore() - const countAtom = atom(0) - const cb = vi.fn() - const unsub = store.sub(countAtom, cb) - store.set(countAtom, 1) - const result = store.dev_get_mounted?.(countAtom) - expect(result).toStrictEqual({ l: new Set([cb]), t: new Set([countAtom]) }) - unsub() - }) - - it('should restore atoms and its dependencies correctly', () => { - const store = createStore() - const countAtom = atom(0) - const derivedAtom = atom((get) => get(countAtom) * 2) - store.set(countAtom, 1) - store.dev_restore_atoms?.([[countAtom, 2]]) - expect(store.get(countAtom)).toBe(2) - expect(store.get?.(derivedAtom)).toBe(4) - }) - - describe('dev_subscribe_store rev2', () => { - it('should call the callback when state changes', () => { - const store = createStore() - const callback = vi.fn() - const unsub = store.dev_subscribe_store?.(callback, 2) - const countAtom = atom(0) - const unsubAtom = store.sub(countAtom, vi.fn()) - store.set(countAtom, 1) - expect(callback).toHaveBeenNthCalledWith(1, { - type: 'sub', - flushed: new Set([countAtom]), - }) - expect(callback).toHaveBeenNthCalledWith(2, { - type: 'write', - flushed: new Set([countAtom]), - }) - expect(callback).toHaveBeenCalledTimes(2) - unsub?.() - unsubAtom?.() - }) - - it('should call unsub only when atom is unsubscribed', () => { - const store = createStore() - const callback = vi.fn() - const unsub = store.dev_subscribe_store?.(callback, 2) - const countAtom = atom(0) - const unsubAtom = store.sub(countAtom, vi.fn()) - const unsubAtomSecond = store.sub(countAtom, vi.fn()) - unsubAtom?.() - expect(callback).toHaveBeenNthCalledWith(1, { - type: 'sub', - flushed: new Set([countAtom]), - }) - expect(callback).toHaveBeenNthCalledWith(2, { - type: 'sub', - flushed: new Set(), - }) - expect(callback).toHaveBeenNthCalledWith(3, { type: 'unsub' }) - expect(callback).toHaveBeenCalledTimes(3) - unsub?.() - unsubAtomSecond?.() - }) - }) - - it('should unmount tree dependencies with store.get', async () => { - const store = createStore() - const countAtom = atom(0) - const derivedAtom = atom((get) => get(countAtom) * 2) - const anotherDerivedAtom = atom((get) => get(countAtom) * 3) - const callback = vi.fn() - const unsubStore = store.dev_subscribe_store?.(() => { - // Comment this line to make the test pass - store.get(derivedAtom) - }, 2) - const unsub = store.sub(anotherDerivedAtom, callback) - unsub() - unsubStore?.() - const result = Array.from(store.dev_get_mounted_atoms?.() ?? []) - expect(result).toEqual([]) - }) -}) - -describe.skipIf(!IS_DEV_STORE2)('[DEV-ONLY] dev-only methods rev4', () => { +describe('[DEV-ONLY] dev-only methods rev4', () => { it('should get atom value', () => { const store = createStore() as any if (!('dev4_get_internal_weak_map' in store)) {