Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/tidy-unobserved-atoms.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/store': patch
---

Allow computed atoms read without a subscription to be garbage collected while preserving cached snapshots and reconnecting dependencies when subscribed.
2 changes: 1 addition & 1 deletion docs/reference/functions/batch.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ title: batch
function batch(fn): void;
```

Defined in: [atom.ts:70](https://github.com/TanStack/store/blob/main/packages/store/src/atom.ts#L70)
Defined in: [atom.ts:67](https://github.com/TanStack/store/blob/main/packages/store/src/atom.ts#L67)

## Parameters

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/functions/createAsyncAtom.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ title: createAsyncAtom
function createAsyncAtom<T>(getValue, options?): ReadonlyAtom<AsyncAtomState<T, unknown>>;
```

Defined in: [atom.ts:108](https://github.com/TanStack/store/blob/main/packages/store/src/atom.ts#L108)
Defined in: [atom.ts:121](https://github.com/TanStack/store/blob/main/packages/store/src/atom.ts#L121)

## Type Parameters

Expand Down
4 changes: 2 additions & 2 deletions docs/reference/functions/createAtom.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ title: createAtom
function createAtom<T>(getValue, options?): ReadonlyAtom<T>;
```

Defined in: [atom.ts:146](https://github.com/TanStack/store/blob/main/packages/store/src/atom.ts#L146)
Defined in: [atom.ts:159](https://github.com/TanStack/store/blob/main/packages/store/src/atom.ts#L159)

### Type Parameters

Expand Down Expand Up @@ -37,7 +37,7 @@ Defined in: [atom.ts:146](https://github.com/TanStack/store/blob/main/packages/s
function createAtom<T>(initialValue, options?): Atom<T>;
```

Defined in: [atom.ts:150](https://github.com/TanStack/store/blob/main/packages/store/src/atom.ts#L150)
Defined in: [atom.ts:163](https://github.com/TanStack/store/blob/main/packages/store/src/atom.ts#L163)

### Type Parameters

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/functions/flush.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ title: flush
function flush(): void;
```

Defined in: [atom.ts:89](https://github.com/TanStack/store/blob/main/packages/store/src/atom.ts#L89)
Defined in: [atom.ts:102](https://github.com/TanStack/store/blob/main/packages/store/src/atom.ts#L102)

## Returns

Expand Down
96 changes: 56 additions & 40 deletions packages/store/src/alien.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,62 +57,76 @@ export function createReactiveSystem({
if (prevDep !== undefined && prevDep.dep === dep) {
return
}
const nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps
let nextDep = prevDep !== undefined ? prevDep.nextDep : sub.deps
const prevSub = dep.subsTail
if (nextDep !== undefined && nextDep.dep === dep) {
nextDep.version = version
sub.depsTail = nextDep
return
}
const prevSub = dep.subsTail
if (
prevSub !== undefined &&
prevSub.version === version &&
prevSub.sub === sub
) {
return
}
const newLink =
(sub.depsTail =
dep.subsTail =
{
version,
dep,
sub,
prevDep,
nextDep,
prevSub,
nextSub: undefined,
})
if (nextDep !== undefined) {
nextDep.prevDep = newLink
}
if (prevDep !== undefined) {
prevDep.nextDep = newLink
// A self pointer marks a retained link absent from the subscriber list.
if (nextDep.prevSub !== nextDep) {
return
}
} else {
sub.deps = newLink
if (
prevSub !== undefined &&
prevSub.version === version &&
prevSub.sub === sub
) {
return
}
const newLink = (sub.depsTail = {
version,
dep,
sub,
prevDep,
nextDep,
prevSub,
nextSub: undefined,
})
if (nextDep !== undefined) {
nextDep.prevDep = newLink
}
if (prevDep !== undefined) {
prevDep.nextDep = newLink
} else {
sub.deps = newLink
}
nextDep = newLink
}
dep.subsTail = nextDep
nextDep.prevSub = prevSub
if (prevSub !== undefined) {
prevSub.nextSub = newLink
prevSub.nextSub = nextDep
} else {
dep.subs = newLink
dep.subs = nextDep
}
}

function unlink(link: Link, sub = link.sub): Link | undefined {
function unlink(
link: Link,
sub = link.sub,
keepDeps = false,
): Link | undefined {
const dep = link.dep
const prevDep = link.prevDep
const nextDep = link.nextDep
const nextSub = link.nextSub
const prevSub = link.prevSub
if (nextDep !== undefined) {
nextDep.prevDep = prevDep
} else {
sub.depsTail = prevDep
// Unobserved atoms retain their forward links for validation and reuse.
if (!keepDeps) {
if (nextDep !== undefined) {
nextDep.prevDep = prevDep
} else {
sub.depsTail = prevDep
}
if (prevDep !== undefined) {
prevDep.nextDep = nextDep
} else {
sub.deps = nextDep
}
}
if (prevDep !== undefined) {
prevDep.nextDep = nextDep
} else {
sub.deps = nextDep
if (prevSub === link) {
return nextDep
}
if (nextSub !== undefined) {
nextSub.prevSub = prevSub
Expand All @@ -124,6 +138,8 @@ export function createReactiveSystem({
} else if ((dep.subs = nextSub) === undefined) {
unwatched(dep)
}
link.prevSub = link
link.nextSub = undefined
return nextDep
}

Expand Down
107 changes: 87 additions & 20 deletions packages/store/src/atom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import {
createReactiveSystem,
} from './alien'

import type { ReactiveNode } from './alien'
import type { Link, ReactiveNode } from './alien'
import type {
Atom,
AtomOptions,
Expand Down Expand Up @@ -36,13 +36,16 @@ export function toObserver<T>(

interface InternalAtom<T> extends ReactiveNode {
_snapshot: T
_version: number
_unwatchedDeps?: boolean
_update: (getValue?: T | ((snapshot: T) => T)) => boolean
get: () => T
subscribe: (observerOrFn: Observer<T> | ((value: T) => void)) => Subscription
}

const queuedEffects: Array<Effect | undefined> = []
let cycle = 0
let writeVersion = 0
const { link, unlink, propagate, checkDirty, shallowPropagate } =
createReactiveSystem({
update(atom: InternalAtom<any>): boolean {
Expand All @@ -53,13 +56,7 @@ const { link, unlink, propagate, checkDirty, shallowPropagate } =
queuedEffects[queuedEffectsLength++] = effect
effect.flags &= ~WATCHING
},
unwatched(atom: InternalAtom<any>): void {
if (atom.depsTail !== undefined) {
atom.depsTail = undefined
atom.flags = MUTABLE | DIRTY
purgeDeps(atom)
}
},
unwatched,
})

let notifyIndex = 0
Expand All @@ -86,6 +83,22 @@ function purgeDeps(sub: ReactiveNode) {
}
}

function unwatched(atom: InternalAtom<any>): void {
if (atom.deps === undefined || atom._unwatchedDeps) {
return
}
atom._unwatchedDeps = true
// Preserve pending changes before replacing tracking cycles with snapshots
// of dependency versions. Detached nodes no longer receive invalidations.
if (atom.flags & PENDING) {
atom.flags = (atom.flags & ~PENDING) | DIRTY
}
for (let dep: Link | undefined = atom.deps; dep; dep = dep.nextDep) {
dep.version = (dep.dep as InternalAtom<any>)._version
unlink(dep, atom, true)
}
}

export function flush(): void {
if (batchDepth > 0) {
return
Expand Down Expand Up @@ -161,6 +174,7 @@ export function createAtom<T>(
// Create plain object atom
const atom: InternalAtom<T> = {
_snapshot: isComputed ? undefined! : valueOrFn,
_version: 0,

subs: undefined,
subsTail: undefined,
Expand Down Expand Up @@ -200,6 +214,7 @@ export function createAtom<T>(
const prevSub = activeSub
const compare = options?.compare ?? Object.is
if (isComputed) {
atom._unwatchedDeps = undefined
activeSub = atom
++cycle
atom.depsTail = undefined
Expand All @@ -221,9 +236,15 @@ export function createAtom<T>(
: getValue!
if (oldValue === undefined || !compare(oldValue, newValue)) {
atom._snapshot = newValue
atom._version = ++writeVersion
return true
}
return false
} catch (error) {
if (isComputed) {
atom.flags |= DIRTY
}
throw error
} finally {
activeSub = prevSub
if (isComputed) {
Expand All @@ -235,23 +256,69 @@ export function createAtom<T>(
}

if (isComputed) {
let checkedVersion = -1
atom.flags = MUTABLE | DIRTY
atom.get = function (): T {
const flags = atom.flags
if (flags & DIRTY || (flags & PENDING && checkDirty(atom.deps!, atom))) {
if (atom._update()) {
const subs = atom.subs
if (subs !== undefined) {
shallowPropagate(subs)
try {
const deps = atom._unwatchedDeps && atom.deps
if (
deps &&
!(atom.flags & DIRTY) &&
(activeSub !== undefined || checkedVersion !== writeVersion)
) {
const prevSub = activeSub
// Subscribing reconnects the cached dependencies without changing the
// snapshot. An untracked read validates them without retaining links.
activeSub = prevSub === undefined ? undefined : atom
if (activeSub !== undefined) {
atom._unwatchedDeps = undefined
atom.depsTail = undefined
}
try {
for (
let depLink: Link | undefined = deps;
depLink;
depLink = depLink.nextDep
) {
const dep = depLink.dep as InternalAtom<any>
const version = depLink.version
dep.get()
if (dep._version !== version) {
atom.flags |= DIRTY
break
}
}
} finally {
activeSub = prevSub
}
}
} else if (flags & PENDING) {
atom.flags = flags & ~PENDING
}
if (activeSub !== undefined) {
link(atom, activeSub, cycle)
const flags = atom.flags
if (
flags & DIRTY ||
(flags & PENDING && checkDirty(atom.deps!, atom))
) {
if (atom._update()) {
const subs = atom.subs
if (subs !== undefined) {
shallowPropagate(subs)
}
}
} else if (flags & PENDING) {
atom.flags = flags & ~PENDING
}
if (activeSub !== undefined) {
link(atom, activeSub, cycle)
}
checkedVersion = writeVersion
return atom._snapshot
} catch (error) {
atom.flags |= DIRTY
throw error
} finally {
if (atom.subs === undefined) {
unwatched(atom)
}
}
return atom._snapshot
}
} else {
;(atom as unknown as Atom<T>).set = function (
Expand Down
Loading
Loading