From 8976a2747abc9ccf2b36424f50f3d269746824fa Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 00:33:21 -0700 Subject: [PATCH 01/23] whileWatched --- packages/store/src/alien.ts | 3 ++ packages/store/src/atom.ts | 69 ++++++++++++++++++++++++++++++++----- packages/store/src/store.ts | 10 ++++-- packages/store/src/types.ts | 13 +++++-- 4 files changed, 83 insertions(+), 12 deletions(-) diff --git a/packages/store/src/alien.ts b/packages/store/src/alien.ts index 7b4cf7df..7005c457 100644 --- a/packages/store/src/alien.ts +++ b/packages/store/src/alien.ts @@ -38,10 +38,12 @@ export const enum ReactiveFlags { export function createReactiveSystem({ update, notify, + watched, unwatched, }: { update(sub: ReactiveNode): boolean notify(sub: ReactiveNode): void + watched(sub: ReactiveNode): void unwatched(sub: ReactiveNode): void }) { return { @@ -95,6 +97,7 @@ export function createReactiveSystem({ prevSub.nextSub = newLink } else { dep.subs = newLink + watched(dep) } } diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index 067b8640..79810b96 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -1,13 +1,13 @@ -import { ReactiveFlags, createReactiveSystem } from './alien' +import { ReactiveFlags, createReactiveSystem } from './alien'; -import type { ReactiveNode } from './alien' +import type { ReactiveNode } from './alien'; import type { - Atom, - AtomOptions, - Observer, - ReadonlyAtom, - Subscription, -} from './types' + Atom, + AtomOptions, + Observer, + ReadonlyAtom, + Subscription, +} from './types'; export function toObserver( nextHandler?: Observer | ((value: T) => void), @@ -26,11 +26,29 @@ export function toObserver( } } +/** + * Called when the atom is watched. + * Returns a cleanup function that will be called when the atom is unwatched. + */ +export type WatchedEffect = () => (() => void) | void | undefined + interface InternalAtom extends ReactiveNode { _snapshot: T _update: (getValue?: T | ((snapshot: T) => T)) => boolean + get: () => T subscribe: (observerOrFn: Observer | ((value: T) => void)) => Subscription + /** + * `effect` will be called while the atom is watched. `effect` may return a + * cleanup function, which will be called when the atom is unwatched. + * + * Returns a `stop` function which cancels the listener. + */ + whileWatched: (effect: WatchedEffect) => () => void + + _watched: boolean + _watchedSubs?: Array + _watchedCleanups?: Array<(() => void) | void | undefined> } const queuedEffects: Array = [] @@ -45,7 +63,21 @@ const { link, unlink, propagate, checkDirty, shallowPropagate } = queuedEffects[queuedEffectsLength++] = effect effect.flags &= ~ReactiveFlags.Watching }, + watched(atom: InternalAtom): void { + atom._watched = true + if (atom._watchedSubs?.length) { + atom._watchedCleanups = atom._watchedSubs.map((sub) => sub()) + } + }, unwatched(atom: InternalAtom): void { + atom._watched = false + // not sure if this should go above or below the deps purge + // preorder vs postorder + if (atom._watchedCleanups?.length) { + atom._watchedCleanups.forEach((cleanup) => cleanup?.()) + atom._watchedCleanups = undefined + } + if (atom.depsTail !== undefined) { atom.depsTail = undefined atom.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty @@ -153,6 +185,7 @@ export function createAtom( // Create plain object atom const atom: InternalAtom = { _snapshot: isComputed ? undefined! : valueOrFn, + _watched: false, subs: undefined, subsTail: undefined, @@ -185,6 +218,26 @@ export function createAtom( }, } }, + + whileWatched(effect: WatchedEffect): () => void { + atom._watchedSubs ??= [] + atom._watchedSubs.push(effect) + if (atom._watched) { + atom._watchedCleanups ??= [] + atom._watchedCleanups.push(effect()) + } + return () => { + if (!atom._watchedSubs) { + return + } + const index = atom._watchedSubs?.indexOf(effect) + if (index !== -1) { + atom._watchedSubs.splice(index, 1) + } + } + }, + + _update(getValue?: T | ((snapshot: T) => T)): boolean { const prevSub = activeSub const compare = options?.compare ?? Object.is diff --git a/packages/store/src/store.ts b/packages/store/src/store.ts index 69a91678..a22e28a9 100644 --- a/packages/store/src/store.ts +++ b/packages/store/src/store.ts @@ -1,5 +1,5 @@ -import { createAtom, toObserver } from './atom' -import type { Atom, Observer, Subscription } from './types' +import { createAtom, toObserver, WatchedEffect } from './atom'; +import type { Atom, Observer, Subscription } from './types'; export type StoreAction = (...args: Array) => any @@ -54,6 +54,9 @@ export class Store { ): Subscription { return this.atom.subscribe(toObserver(observerOrFn)) } + public whileWatched(effect: WatchedEffect): () => void { + return this.atom.whileWatched(effect) + } } export class ReadonlyStore implements Omit< @@ -81,6 +84,9 @@ export class ReadonlyStore implements Omit< ): Subscription { return this.atom.subscribe(toObserver(observerOrFn)) } + public whileWatched(effect: WatchedEffect): () => void { + return this.atom.whileWatched(effect) + } } export function createStore( diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index 24df6663..030ec41b 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -1,4 +1,5 @@ -import type { ReactiveNode } from './alien' +import type { ReactiveNode } from './alien'; +import { WatchedEffect } from './atom'; export type Selection = Readable @@ -30,7 +31,15 @@ export interface Readable extends Subscribable { get: () => T } -export interface BaseAtom extends Subscribable, Readable {} +export interface BaseAtom extends Subscribable, Readable { + /** + * `effect` will be called while the atom is watched. `effect` may return a + * cleanup function, which will be called when the atom is unwatched. + * + * Returns a `stop` function which cancels the listener. + */ + whileWatched: (effect: WatchedEffect) => () => void +} export interface InternalBaseAtom extends Subscribable, Readable { /** @internal */ From 3028dd8a4717675b75684d936ef313ab0c6ab660 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 08:20:37 -0700 Subject: [PATCH 02/23] whileWatchd test --- packages/store/tests/while-watched.test.ts | 171 +++++++++++++++++++++ 1 file changed, 171 insertions(+) create mode 100644 packages/store/tests/while-watched.test.ts diff --git a/packages/store/tests/while-watched.test.ts b/packages/store/tests/while-watched.test.ts new file mode 100644 index 00000000..40b7e2d5 --- /dev/null +++ b/packages/store/tests/while-watched.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, test, vi } from 'vitest' +import { createAtom } from '../src' + +describe('whileWatched', () => { + test('does not fire before any subscriber', () => { + const atom = createAtom(0) + const effect = vi.fn() + atom.whileWatched(effect) + + expect(effect).not.toHaveBeenCalled() + }) + + test('fires on first subscribe; cleanup runs on last unsubscribe', () => { + const atom = createAtom(0) + const cleanup = vi.fn() + const effect = vi.fn(() => cleanup) + atom.whileWatched(effect) + + const sub = atom.subscribe(() => {}) + expect(effect).toHaveBeenCalledTimes(1) + expect(cleanup).not.toHaveBeenCalled() + + sub.unsubscribe() + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + test('is ref-counted: multiple subscribers fire effect exactly once', () => { + const atom = createAtom(0) + const cleanup = vi.fn() + const effect = vi.fn(() => cleanup) + atom.whileWatched(effect) + + const sub1 = atom.subscribe(() => {}) + const sub2 = atom.subscribe(() => {}) + const sub3 = atom.subscribe(() => {}) + expect(effect).toHaveBeenCalledTimes(1) + + sub1.unsubscribe() + sub2.unsubscribe() + expect(cleanup).not.toHaveBeenCalled() + + sub3.unsubscribe() + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + test('re-fires on each 0→1 transition and cleans up on each 1→0', () => { + const atom = createAtom(0) + const cleanup = vi.fn() + const effect = vi.fn(() => cleanup) + atom.whileWatched(effect) + + atom.subscribe(() => {}).unsubscribe() + atom.subscribe(() => {}).unsubscribe() + atom.subscribe(() => {}).unsubscribe() + + expect(effect).toHaveBeenCalledTimes(3) + expect(cleanup).toHaveBeenCalledTimes(3) + }) + + test('registering while already watched fires immediately', () => { + const atom = createAtom(0) + const sub = atom.subscribe(() => {}) + + const cleanup = vi.fn() + const effect = vi.fn(() => cleanup) + atom.whileWatched(effect) + + expect(effect).toHaveBeenCalledTimes(1) + expect(cleanup).not.toHaveBeenCalled() + + sub.unsubscribe() + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + test('multiple handlers all fire and all clean up', () => { + const atom = createAtom(0) + const cleanupA = vi.fn() + const cleanupB = vi.fn() + const effectA = vi.fn(() => cleanupA) + const effectB = vi.fn(() => cleanupB) + atom.whileWatched(effectA) + atom.whileWatched(effectB) + + const sub = atom.subscribe(() => {}) + expect(effectA).toHaveBeenCalledTimes(1) + expect(effectB).toHaveBeenCalledTimes(1) + + sub.unsubscribe() + expect(cleanupA).toHaveBeenCalledTimes(1) + expect(cleanupB).toHaveBeenCalledTimes(1) + }) + + test('effect returning void does not throw on unwatch', () => { + const atom = createAtom(0) + atom.whileWatched(() => { + // no cleanup + }) + + const sub = atom.subscribe(() => {}) + expect(() => sub.unsubscribe()).not.toThrow() + }) + + test('stop() prevents future activations', () => { + const atom = createAtom(0) + const effect = vi.fn() + const stop = atom.whileWatched(effect) + + atom.subscribe(() => {}).unsubscribe() + expect(effect).toHaveBeenCalledTimes(1) + + stop() + + atom.subscribe(() => {}).unsubscribe() + expect(effect).toHaveBeenCalledTimes(1) + }) + + test('stop() is idempotent', () => { + const atom = createAtom(0) + const stop = atom.whileWatched(() => {}) + expect(() => { + stop() + stop() + }).not.toThrow() + }) + + test('works on computed atoms', () => { + const source = createAtom(1) + const derived = createAtom(() => source.get() * 2) + + const cleanup = vi.fn() + const effect = vi.fn(() => cleanup) + derived.whileWatched(effect) + + const sub = derived.subscribe(() => {}) + expect(effect).toHaveBeenCalledTimes(1) + + sub.unsubscribe() + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + test('cascades through computed deps: watching a derived atom watches its sources', () => { + const source = createAtom(1) + const derived = createAtom(() => source.get() * 2) + + const sourceCleanup = vi.fn() + const sourceEffect = vi.fn(() => sourceCleanup) + source.whileWatched(sourceEffect) + + const sub = derived.subscribe(() => {}) + expect(sourceEffect).toHaveBeenCalledTimes(1) + expect(sourceCleanup).not.toHaveBeenCalled() + + sub.unsubscribe() + expect(sourceCleanup).toHaveBeenCalledTimes(1) + }) + + test('subscribing directly to the source does not double-fire when a derived is also subscribed', () => { + const source = createAtom(1) + const derived = createAtom(() => source.get() * 2) + + const effect = vi.fn() + source.whileWatched(effect) + + const subDerived = derived.subscribe(() => {}) + const subSource = source.subscribe(() => {}) + expect(effect).toHaveBeenCalledTimes(1) + + subDerived.unsubscribe() + subSource.unsubscribe() + }) +}) From ac60335460390e14e359f9c54fad42912728ee94 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 08:30:27 -0700 Subject: [PATCH 03/23] import order --- packages/store/src/store.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/store/src/store.ts b/packages/store/src/store.ts index a22e28a9..0745dd16 100644 --- a/packages/store/src/store.ts +++ b/packages/store/src/store.ts @@ -1,4 +1,5 @@ -import { createAtom, toObserver, WatchedEffect } from './atom'; +import { createAtom, toObserver } from './atom'; +import type { WatchedEffect } from './atom'; import type { Atom, Observer, Subscription } from './types'; export type StoreAction = (...args: Array) => any From 2a265c6294fbb17be27cf09dd0b3cf8028a9ccbd Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 09:21:35 -0700 Subject: [PATCH 04/23] update doc example --- packages/store/src/types.ts | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index 030ec41b..c4f2dedc 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -33,10 +33,31 @@ export interface Readable extends Subscribable { export interface BaseAtom extends Subscribable, Readable { /** - * `effect` will be called while the atom is watched. `effect` may return a - * cleanup function, which will be called when the atom is unwatched. - * - * Returns a `stop` function which cancels the listener. + * `effect` will be called when the first subscriber is added. `effect` + * may return a cleanup function, which will be called when the atom is + * unwatched. + * + * Returns an `cleanup` function which removes the `whileWatched` listener. + * + * This can be used to sync external resources into the atom, similar to + * `useLayoutEffect` in React. + * + * ```ts + * function createTicker(ms: number) { + * const now = createAtom(Date.now()) + * const refresh = () => now.set(Date.now()) + * now.whileWatched(() => { + * refresh() + * const interval = setInterval(refresh, ms) + * return () => clearInterval(interval) + * }) + * return createAtom(() => now.get()) + * } + * const ticker = createTicker(1000) + * ticker.subscribe(() => { + * console.log('current time: ', ticker.get()) + * }) + * ``` */ whileWatched: (effect: WatchedEffect) => () => void } From 73b114571cedf6de852efd57e8187af92c39038a Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 09:23:52 -0700 Subject: [PATCH 05/23] type error in (unused) singal.ts --- packages/store/src/signal.ts | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/store/src/signal.ts b/packages/store/src/signal.ts index c05bf26a..7c66f289 100644 --- a/packages/store/src/signal.ts +++ b/packages/store/src/signal.ts @@ -60,6 +60,9 @@ const { link, unlink, propagate, checkDirty, shallowPropagate } = queued[insertIndex] = left } }, + watched(_node) { + // whileWatched not implemented for signal.ts + }, unwatched(node) { if (!(node.flags & ReactiveFlags.Mutable)) { effectScopeOper.call(node) From 176c5e313cf08b3ed374c695fa2a6c370906489f Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 09:26:31 -0700 Subject: [PATCH 06/23] createExternalStoreAtom --- packages/store/src/atom.ts | 63 +++++-- packages/store/src/store.ts | 6 +- packages/store/src/types.ts | 4 +- .../store/tests/external-store-atom.test.ts | 163 ++++++++++++++++++ 4 files changed, 216 insertions(+), 20 deletions(-) create mode 100644 packages/store/tests/external-store-atom.test.ts diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index 79810b96..094e6ac3 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -1,13 +1,13 @@ -import { ReactiveFlags, createReactiveSystem } from './alien'; +import { ReactiveFlags, createReactiveSystem } from './alien' -import type { ReactiveNode } from './alien'; +import type { ReactiveNode } from './alien' import type { - Atom, - AtomOptions, - Observer, - ReadonlyAtom, - Subscription, -} from './types'; + Atom, + AtomOptions, + Observer, + ReadonlyAtom, + Subscription, +} from './types' export function toObserver( nextHandler?: Observer | ((value: T) => void), @@ -41,7 +41,7 @@ interface InternalAtom extends ReactiveNode { /** * `effect` will be called while the atom is watched. `effect` may return a * cleanup function, which will be called when the atom is unwatched. - * + * * Returns a `stop` function which cancels the listener. */ whileWatched: (effect: WatchedEffect) => () => void @@ -75,7 +75,7 @@ const { link, unlink, propagate, checkDirty, shallowPropagate } = // preorder vs postorder if (atom._watchedCleanups?.length) { atom._watchedCleanups.forEach((cleanup) => cleanup?.()) - atom._watchedCleanups = undefined + atom._watchedCleanups.length = 0 } if (atom.depsTail !== undefined) { @@ -167,6 +167,40 @@ export function createAsyncAtom( return atom } +/** + * Like React.useSyncExternalStore: pulls external state into an atom. + * Thic can be used for interoperating with other state management libraries. + * + * ```ts + * import * as redux from "redux" + * + * const reduxStore = redux.createStore((state: number, action: number) => state + action, 0) + * const atom = createExternalStoreAtom(reduxStore.getState, reduxStore.subscribe) + * + * const timesTwo = createAtom(() => atom.get() * 2) + * timesTwo.subscribe((value) => { + * console.log('timesTwo: ', value) + * }) + * + * reduxStore.dispatch(1) + * // timesTwo: 2 + * reduxStore.dispatch(1) + * // timesTwo: 4 + */ +export function createExternalStoreAtom( + getSnapshot: () => T, + subscribe: (onStoreChange: () => void) => () => void, + options?: AtomOptions, +): ReadonlyAtom { + const mutable = createAtom({ value: getSnapshot() }) + const updateFromExternalStore = () => mutable.set({ value: getSnapshot() }) + mutable.whileWatched(() => { + updateFromExternalStore() + return subscribe(updateFromExternalStore) + }) + return createAtom(() => mutable.get().value, options) +} + export function createAtom( getValue: (prev?: NoInfer) => T, options?: AtomOptions, @@ -219,25 +253,24 @@ export function createAtom( } }, - whileWatched(effect: WatchedEffect): () => void { + whileWatched(listener: WatchedEffect): () => void { atom._watchedSubs ??= [] - atom._watchedSubs.push(effect) + atom._watchedSubs.push(listener) if (atom._watched) { atom._watchedCleanups ??= [] - atom._watchedCleanups.push(effect()) + atom._watchedCleanups.push(listener()) } return () => { if (!atom._watchedSubs) { return } - const index = atom._watchedSubs?.indexOf(effect) + const index = atom._watchedSubs.indexOf(listener) if (index !== -1) { atom._watchedSubs.splice(index, 1) } } }, - _update(getValue?: T | ((snapshot: T) => T)): boolean { const prevSub = activeSub const compare = options?.compare ?? Object.is diff --git a/packages/store/src/store.ts b/packages/store/src/store.ts index 0745dd16..e28274b0 100644 --- a/packages/store/src/store.ts +++ b/packages/store/src/store.ts @@ -1,6 +1,6 @@ -import { createAtom, toObserver } from './atom'; -import type { WatchedEffect } from './atom'; -import type { Atom, Observer, Subscription } from './types'; +import { createAtom, toObserver } from './atom' +import type { WatchedEffect } from './atom' +import type { Atom, Observer, Subscription } from './types' export type StoreAction = (...args: Array) => any diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index c4f2dedc..95ac62c0 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -1,5 +1,5 @@ -import type { ReactiveNode } from './alien'; -import { WatchedEffect } from './atom'; +import type { ReactiveNode } from './alien' +import { WatchedEffect } from './atom' export type Selection = Readable diff --git a/packages/store/tests/external-store-atom.test.ts b/packages/store/tests/external-store-atom.test.ts new file mode 100644 index 00000000..9b42fa92 --- /dev/null +++ b/packages/store/tests/external-store-atom.test.ts @@ -0,0 +1,163 @@ +import { describe, expect, test, vi } from 'vitest' +import { createExternalStoreAtom } from '../src' + +function makeExternalStore(initial: T) { + let value = initial + const listeners = new Set<() => void>() + return { + getSnapshot: () => value, + set(next: T) { + value = next + listeners.forEach((l) => l()) + }, + subscribe(cb: () => void) { + listeners.add(cb) + return () => { + listeners.delete(cb) + } + }, + listenerCount: () => listeners.size, + } +} + +describe('createExternalStoreAtom', () => { + test('initial value is getSnapshot() at creation time', () => { + const ext = makeExternalStore(42) + const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + expect(atom.get()).toBe(42) + }) + + test('does not subscribe to the external store without subscribers', () => { + const ext = makeExternalStore(0) + createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + expect(ext.listenerCount()).toBe(0) + }) + + test('subscribes on first atom subscriber; unsubscribes on last', () => { + const ext = makeExternalStore(0) + const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + + const sub = atom.subscribe(() => {}) + expect(ext.listenerCount()).toBe(1) + + sub.unsubscribe() + expect(ext.listenerCount()).toBe(0) + }) + + test('pulls a fresh snapshot on activation (external changes while nobody watching)', () => { + const ext = makeExternalStore(0) + const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + + ext.set(5) + + const received: Array = [] + const sub = atom.subscribe((v) => received.push(v)) + expect(atom.get()).toBe(5) + + sub.unsubscribe() + }) + + test('notifies subscribers when the external store dispatches changes', () => { + const ext = makeExternalStore(0) + const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + + const fn = vi.fn() + const sub = atom.subscribe(fn) + + ext.set(1) + ext.set(2) + ext.set(3) + + expect(fn).toHaveBeenNthCalledWith(1, 1) + expect(fn).toHaveBeenNthCalledWith(2, 2) + expect(fn).toHaveBeenNthCalledWith(3, 3) + expect(fn).toHaveBeenCalledTimes(3) + + sub.unsubscribe() + }) + + test('multiple subscribers share a single external subscription', () => { + const ext = makeExternalStore(0) + const subscribe = vi.fn(ext.subscribe) + const atom = createExternalStoreAtom(ext.getSnapshot, subscribe) + + const s1 = atom.subscribe(() => {}) + const s2 = atom.subscribe(() => {}) + const s3 = atom.subscribe(() => {}) + + expect(subscribe).toHaveBeenCalledTimes(1) + expect(ext.listenerCount()).toBe(1) + + s1.unsubscribe() + s2.unsubscribe() + expect(ext.listenerCount()).toBe(1) + + s3.unsubscribe() + expect(ext.listenerCount()).toBe(0) + }) + + test('re-subscribing after full teardown re-subscribes to the external store', () => { + const ext = makeExternalStore(0) + const subscribe = vi.fn(ext.subscribe) + const atom = createExternalStoreAtom(ext.getSnapshot, subscribe) + + atom.subscribe(() => {}).unsubscribe() + expect(ext.listenerCount()).toBe(0) + + atom.subscribe(() => {}).unsubscribe() + expect(subscribe).toHaveBeenCalledTimes(2) + }) + + test('deduplicates: no notification when external value is Object.is-equal', () => { + const ext = makeExternalStore({ x: 1 }) + const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + + const fn = vi.fn() + const sub = atom.subscribe(fn) + + ext.set(ext.getSnapshot()) + ext.set(ext.getSnapshot()) + expect(fn).not.toHaveBeenCalled() + + ext.set({ x: 1 }) + expect(fn).toHaveBeenCalledTimes(1) + + sub.unsubscribe() + }) + + test('does not infinite-loop when a subscriber triggers another external set', () => { + const ext = makeExternalStore(0) + const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + + const seen: Array = [] + const sub = atom.subscribe((v) => { + seen.push(v) + if (v === 1) ext.set(2) + }) + + ext.set(1) + + expect(seen).toEqual([1]) + expect(atom.get()).toBe(2) + + sub.unsubscribe() + }) + + test('works as a dep inside another computed atom', async () => { + const { createAtom } = await import('../src') + const ext = makeExternalStore(10) + const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + const doubled = createAtom(() => atom.get() * 2) + + const fn = vi.fn() + const sub = doubled.subscribe(fn) + expect(doubled.get()).toBe(20) + + ext.set(7) + expect(doubled.get()).toBe(14) + expect(fn).toHaveBeenLastCalledWith(14) + + sub.unsubscribe() + expect(ext.listenerCount()).toBe(0) + }) +}) From b852cb8698a56e3509cb54fca827ca159c199f66 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 09:32:30 -0700 Subject: [PATCH 07/23] Update docs --- .../functions/createExternalStoreAtom.md | 58 +++++++++++++++++++ docs/reference/type-aliases/WatchedEffect.md | 19 ++++++ packages/store/src/store.ts | 12 ++++ 3 files changed, 89 insertions(+) create mode 100644 docs/reference/functions/createExternalStoreAtom.md create mode 100644 docs/reference/type-aliases/WatchedEffect.md diff --git a/docs/reference/functions/createExternalStoreAtom.md b/docs/reference/functions/createExternalStoreAtom.md new file mode 100644 index 00000000..1658f4e7 --- /dev/null +++ b/docs/reference/functions/createExternalStoreAtom.md @@ -0,0 +1,58 @@ +--- +id: createExternalStoreAtom +title: createExternalStoreAtom +--- + +# Function: createExternalStoreAtom() + +```ts +function createExternalStoreAtom( + getSnapshot, + subscribe, +options?): ReadonlyAtom; +``` + +Defined in: [atom.ts:190](https://github.com/justjake/store/blob/main/packages/store/src/atom.ts#L190) + +Like React.useSyncExternalStore: pulls external state into an atom. +Thic can be used for interoperating with other state management libraries. + +```ts +import * as redux from "redux" + +const reduxStore = redux.createStore((state: number, action: number) => state + action, 0) +const atom = createExternalStoreAtom(reduxStore.getState, reduxStore.subscribe) + +const timesTwo = createAtom(() => atom.get() * 2) +timesTwo.subscribe((value) => { + console.log('timesTwo: ', value) +}) + +reduxStore.dispatch(1) +// timesTwo: 2 +reduxStore.dispatch(1) +// timesTwo: 4 + +## Type Parameters + +### T + +`T` + +## Parameters + +### getSnapshot + +() => `T` + +### subscribe + +(`onStoreChange`) => () => `void` + +### options? + +[`AtomOptions`](../interfaces/AtomOptions.md)\<`T`\> + +## Returns + +[`ReadonlyAtom`](../interfaces/ReadonlyAtom.md)\<`T`\> diff --git a/docs/reference/type-aliases/WatchedEffect.md b/docs/reference/type-aliases/WatchedEffect.md new file mode 100644 index 00000000..c5384612 --- /dev/null +++ b/docs/reference/type-aliases/WatchedEffect.md @@ -0,0 +1,19 @@ +--- +id: WatchedEffect +title: WatchedEffect +--- + +# Type Alias: WatchedEffect() + +```ts +type WatchedEffect = () => () => void | void | undefined; +``` + +Defined in: [atom.ts:33](https://github.com/justjake/store/blob/main/packages/store/src/atom.ts#L33) + +Called when the atom is watched. +Returns a cleanup function that will be called when the atom is unwatched. + +## Returns + +() => `void` \| `void` \| `undefined` diff --git a/packages/store/src/store.ts b/packages/store/src/store.ts index e28274b0..00bca4f1 100644 --- a/packages/store/src/store.ts +++ b/packages/store/src/store.ts @@ -55,6 +55,12 @@ export class Store { ): Subscription { return this.atom.subscribe(toObserver(observerOrFn)) } + /** + * `effect` will be called while the atom is watched. `effect` may return a + * cleanup function, which will be called when the atom is unwatched. + * + * Returns a `stop` function which cancels the listener. + */ public whileWatched(effect: WatchedEffect): () => void { return this.atom.whileWatched(effect) } @@ -85,6 +91,12 @@ export class ReadonlyStore implements Omit< ): Subscription { return this.atom.subscribe(toObserver(observerOrFn)) } + /** + * `effect` will be called while the atom is watched. `effect` may return a + * cleanup function, which will be called when the atom is unwatched. + * + * Returns a `stop` function which cancels the listener. + */ public whileWatched(effect: WatchedEffect): () => void { return this.atom.whileWatched(effect) } From 5c0044674590590d51983116bf78c81a8bf3e327 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 09:34:25 -0700 Subject: [PATCH 08/23] fix lint --- packages/store/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index 95ac62c0..25412de8 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -1,5 +1,5 @@ +import type { WatchedEffect } from './atom' import type { ReactiveNode } from './alien' -import { WatchedEffect } from './atom' export type Selection = Readable From 284bc0e5ec4ccdd670e86661cd24c10d10f92d67 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 09:39:20 -0700 Subject: [PATCH 09/23] add changeset --- .changeset/ninety-actors-hide.md | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .changeset/ninety-actors-hide.md diff --git a/.changeset/ninety-actors-hide.md b/.changeset/ninety-actors-hide.md new file mode 100644 index 00000000..09bf8116 --- /dev/null +++ b/.changeset/ninety-actors-hide.md @@ -0,0 +1,11 @@ +--- +'@tanstack/angular-store': minor +'@tanstack/preact-store': minor +'@tanstack/react-store': minor +'@tanstack/solid-store': minor +'@tanstack/vue-store': minor +'@tanstack/store': minor +'@tanstack/svelte-store': minor +--- + +Added atom.whileWatched(cb), store.whileWatched(cb), createExternalStoreAtom From 4c381bb239b64310bfea4cd389e779ec6e65eafb Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 09:43:38 -0700 Subject: [PATCH 10/23] update docs/config.json for new exports --- docs/config.json | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/config.json b/docs/config.json index 7f9a3bdb..f423dbb5 100644 --- a/docs/config.json +++ b/docs/config.json @@ -225,6 +225,10 @@ "label": "StoreActionsFactory", "to": "reference/type-aliases/StoreActionsFactory" }, + { + "label": "WatchedEffect", + "to": "reference/type-aliases/WatchedEffect" + }, { "label": "batch", "to": "reference/functions/batch" @@ -237,6 +241,10 @@ "label": "createAtom", "to": "reference/functions/createAtom" }, + { + "label": "createExternalStoreAtom", + "to": "reference/functions/createExternalStoreAtom" + }, { "label": "createStore", "to": "reference/functions/createStore" From 5e144d18fa3d6c1244e29a84a1750fe9713ab12d Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 10:18:50 -0700 Subject: [PATCH 11/23] always refetch --- packages/store/src/atom.ts | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index 094e6ac3..4a5e48d4 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -192,13 +192,15 @@ export function createExternalStoreAtom( subscribe: (onStoreChange: () => void) => () => void, options?: AtomOptions, ): ReadonlyAtom { - const mutable = createAtom({ value: getSnapshot() }) - const updateFromExternalStore = () => mutable.set({ value: getSnapshot() }) - mutable.whileWatched(() => { - updateFromExternalStore() - return subscribe(updateFromExternalStore) - }) - return createAtom(() => mutable.get().value, options) + const trigger = createAtom(0) + const invalidate = () => trigger.set((n) => n + 1) + trigger.whileWatched(() => subscribe(invalidate)) + + return createAtom(() => { + // Return latest snapshot when `trigger` changes + trigger.get() + return getSnapshot() + }, options) } export function createAtom( From 7e47c656dd0e83dfc6966474c220bf1b881d2c16 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 10:38:41 -0700 Subject: [PATCH 12/23] typo --- packages/store/src/atom.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index 4a5e48d4..d1d1de4a 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -169,7 +169,7 @@ export function createAsyncAtom( /** * Like React.useSyncExternalStore: pulls external state into an atom. - * Thic can be used for interoperating with other state management libraries. + * This can be used for interoperating with other state management libraries. * * ```ts * import * as redux from "redux" From 000d6bfc9d079dfd6f83d8da5d7a7d453aae584e Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 10:39:17 -0700 Subject: [PATCH 13/23] remove docs pointing to pr branch --- .../functions/createExternalStoreAtom.md | 58 ------------------- docs/reference/type-aliases/WatchedEffect.md | 19 ------ 2 files changed, 77 deletions(-) delete mode 100644 docs/reference/functions/createExternalStoreAtom.md delete mode 100644 docs/reference/type-aliases/WatchedEffect.md diff --git a/docs/reference/functions/createExternalStoreAtom.md b/docs/reference/functions/createExternalStoreAtom.md deleted file mode 100644 index 1658f4e7..00000000 --- a/docs/reference/functions/createExternalStoreAtom.md +++ /dev/null @@ -1,58 +0,0 @@ ---- -id: createExternalStoreAtom -title: createExternalStoreAtom ---- - -# Function: createExternalStoreAtom() - -```ts -function createExternalStoreAtom( - getSnapshot, - subscribe, -options?): ReadonlyAtom; -``` - -Defined in: [atom.ts:190](https://github.com/justjake/store/blob/main/packages/store/src/atom.ts#L190) - -Like React.useSyncExternalStore: pulls external state into an atom. -Thic can be used for interoperating with other state management libraries. - -```ts -import * as redux from "redux" - -const reduxStore = redux.createStore((state: number, action: number) => state + action, 0) -const atom = createExternalStoreAtom(reduxStore.getState, reduxStore.subscribe) - -const timesTwo = createAtom(() => atom.get() * 2) -timesTwo.subscribe((value) => { - console.log('timesTwo: ', value) -}) - -reduxStore.dispatch(1) -// timesTwo: 2 -reduxStore.dispatch(1) -// timesTwo: 4 - -## Type Parameters - -### T - -`T` - -## Parameters - -### getSnapshot - -() => `T` - -### subscribe - -(`onStoreChange`) => () => `void` - -### options? - -[`AtomOptions`](../interfaces/AtomOptions.md)\<`T`\> - -## Returns - -[`ReadonlyAtom`](../interfaces/ReadonlyAtom.md)\<`T`\> diff --git a/docs/reference/type-aliases/WatchedEffect.md b/docs/reference/type-aliases/WatchedEffect.md deleted file mode 100644 index c5384612..00000000 --- a/docs/reference/type-aliases/WatchedEffect.md +++ /dev/null @@ -1,19 +0,0 @@ ---- -id: WatchedEffect -title: WatchedEffect ---- - -# Type Alias: WatchedEffect() - -```ts -type WatchedEffect = () => () => void | void | undefined; -``` - -Defined in: [atom.ts:33](https://github.com/justjake/store/blob/main/packages/store/src/atom.ts#L33) - -Called when the atom is watched. -Returns a cleanup function that will be called when the atom is unwatched. - -## Returns - -() => `void` \| `void` \| `undefined` From 1b5acd129dac1c0d7755dab18efcf9448226a10e Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 10:40:14 -0700 Subject: [PATCH 14/23] more doc typo --- packages/store/src/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index 25412de8..b0da72f5 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -37,7 +37,7 @@ export interface BaseAtom extends Subscribable, Readable { * may return a cleanup function, which will be called when the atom is * unwatched. * - * Returns an `cleanup` function which removes the `whileWatched` listener. + * Returns a `cleanup` function which removes the `whileWatched` listener. * * This can be used to sync external resources into the atom, similar to * `useLayoutEffect` in React. From 6dc28fbcf83981a35dd686c7bb56393f4b6e315a Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Fri, 17 Apr 2026 11:05:10 -0700 Subject: [PATCH 15/23] add missing end codefence in doc --- packages/store/src/atom.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index d1d1de4a..d058d5ae 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -186,6 +186,7 @@ export function createAsyncAtom( * // timesTwo: 2 * reduxStore.dispatch(1) * // timesTwo: 4 + * ``` */ export function createExternalStoreAtom( getSnapshot: () => T, From 3e00449fe20e1a73b8cd2be2afea68911efec3e7 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 22 Apr 2026 18:00:27 -0600 Subject: [PATCH 16/23] revert alien --- packages/store/src/alien.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/store/src/alien.ts b/packages/store/src/alien.ts index 7005c457..7b4cf7df 100644 --- a/packages/store/src/alien.ts +++ b/packages/store/src/alien.ts @@ -38,12 +38,10 @@ export const enum ReactiveFlags { export function createReactiveSystem({ update, notify, - watched, unwatched, }: { update(sub: ReactiveNode): boolean notify(sub: ReactiveNode): void - watched(sub: ReactiveNode): void unwatched(sub: ReactiveNode): void }) { return { @@ -97,7 +95,6 @@ export function createReactiveSystem({ prevSub.nextSub = newLink } else { dep.subs = newLink - watched(dep) } } From 8fb68e67c716b32fc2914d087c9a4635cc9c9f04 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 22 Apr 2026 18:19:50 -0600 Subject: [PATCH 17/23] port notion watched algo --- packages/store/src/atom.ts | 141 +++++++++++++++++++++++++++++++++---- 1 file changed, 129 insertions(+), 12 deletions(-) diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index d058d5ae..86957325 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -1,6 +1,7 @@ +import { destroyPlatform } from '@angular/core'; import { ReactiveFlags, createReactiveSystem } from './alien' -import type { ReactiveNode } from './alien' +import type { Link, ReactiveNode } from './alien' import type { Atom, AtomOptions, @@ -26,11 +27,103 @@ export function toObserver( } } +export type WatchedEffect = () => (() => void) | void | undefined + +interface WatchableNode extends ReactiveNode { + _watches?: number + _watchEffects?: Array + _watchCleanups?: Array<(() => void) | void | undefined> +} + +function getWatchCount(node: WatchableNode): number { + return node._watches || (node.flags & ReactiveFlags.Watching) ? 1 : 0 +} + +function addWatch(node: WatchableNode) { + node._watches ??= 0 + const prev = node._watches++ + + // On first watch, node becomes alive: + if (prev === 0) { + // 1. propagate liveness to deps + // We become alive *after* everything we depend on becomes watched. + // (set up dependencies first before us, the subscriber.) + let deps = node.deps + while (deps !== undefined) { + addWatch(deps.dep) + deps = deps.nextDep + } + + // 2. start/run watch effects + const watchEffects = node._watchEffects + if (watchEffects?.length) { + node._watchCleanups = watchEffects.map((ef) => ef()) + } + } +} + +function removeWatch(node: WatchableNode) { + node._watches ??= 0 + const next = --node._watches + + // On last unwatch, node becomes dead: + if (next === 0) { + // 1. Clean up effects + // We clean up subs before we clean up their deps (no use after free) + node._watchCleanups?.forEach((cleanup) => cleanup?.()) + node._watchCleanups = undefined + + // 2. propagate unwatch to deps + let deps = node.depsTail + while (deps !== undefined) { + removeWatch(deps.dep) + deps = deps.prevDep + } + } +} + +export function whileWatched(node: WatchableNode, fn: WatchedEffect) { + const initialEffects = (node._watchEffects ??= []) + initialEffects.push(fn) + if (node._watches) { + // Node is already watched, start effect immediately + const cleanups = (node._watchCleanups ??= []) + cleanups.push(fn()) + } + + return function removeWhileWatched() { + const stoppableEffects = node._watchEffects + if (!stoppableEffects) { + return + } + + const index = stoppableEffects.indexOf(fn) + if (index === -1) { + return + } + + stoppableEffects.splice(index, 1) + + if (node._watches) { + // If node is watched when we remove, + // also clean up the effect immediately + const watchCleanups = node._watchCleanups + if (watchCleanups?.length) { + const cleanup = watchCleanups[index] + cleanup?.() + watchCleanups.splice(index, 1) + if (watchCleanups.length === 0) { + node._watchCleanups = undefined + } + } + } + } +} + /** * Called when the atom is watched. * Returns a cleanup function that will be called when the atom is unwatched. */ -export type WatchedEffect = () => (() => void) | void | undefined interface InternalAtom extends ReactiveNode { _snapshot: T @@ -53,7 +146,7 @@ interface InternalAtom extends ReactiveNode { const queuedEffects: Array = [] let cycle = 0 -const { link, unlink, propagate, checkDirty, shallowPropagate } = +const { link: _link, unlink: _unlink, propagate, checkDirty, shallowPropagate } = createReactiveSystem({ update(atom: InternalAtom): boolean { return atom._update() @@ -63,12 +156,6 @@ const { link, unlink, propagate, checkDirty, shallowPropagate } = queuedEffects[queuedEffectsLength++] = effect effect.flags &= ~ReactiveFlags.Watching }, - watched(atom: InternalAtom): void { - atom._watched = true - if (atom._watchedSubs?.length) { - atom._watchedCleanups = atom._watchedSubs.map((sub) => sub()) - } - }, unwatched(atom: InternalAtom): void { atom._watched = false // not sure if this should go above or below the deps purge @@ -86,6 +173,31 @@ const { link, unlink, propagate, checkDirty, shallowPropagate } = }, }) +function link(dep: ReactiveNode, sub: ReactiveNode, version: number) { + const originalTail = dep.subsTail + _link(dep, sub, version) + const newTail = dep.subsTail + + if (newTail && newTail !== originalTail && getWatchCount(sub)) { + // Propagate watch liveness from sub -> dep + addWatch(dep) + } +} + + +function unlink( + link: Link, + // sub must ALWAYS be link.sub, this arg is here for micro-optimization + sub: ReactiveNode = link.sub +): Link | undefined { + const dep = link.dep + if (getWatchCount(sub)) { + // Revoke liveness from this sub on dep when unlinked + removeWatch(dep) + } + return _unlink(link, sub) +} + let notifyIndex = 0 let queuedEffectsLength = 0 let activeSub: ReactiveNode | undefined @@ -195,13 +307,18 @@ export function createExternalStoreAtom( ): ReadonlyAtom { const trigger = createAtom(0) const invalidate = () => trigger.set((n) => n + 1) - trigger.whileWatched(() => subscribe(invalidate)) - - return createAtom(() => { + const atom = createAtom(() => { // Return latest snapshot when `trigger` changes trigger.get() return getSnapshot() }, options) + // Attach whileWatched to `atom`, not `trigger`. An unobserved `atom.get()` + // runs the getter with `activeSub = atom`, creating a trigger → atom link and + // firing `watched(trigger)` — but trigger has no whileWatched callback, so + // nothing happens. `watched(atom)` only fires when a real subscriber actually + // links in via subscribe/effect, which is what we want. + atom.whileWatched(() => subscribe(invalidate)) + return atom } export function createAtom( From 75f85cb878d98106dd1c26aaeaba3800f262d52c Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 22 Apr 2026 18:48:04 -0600 Subject: [PATCH 18/23] effects spawn w/ _watches=1 so they are alive by default --- packages/store/src/atom.ts | 48 ++++++++----------- .../store/tests/external-store-atom.test.ts | 23 +++++++++ 2 files changed, 42 insertions(+), 29 deletions(-) diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index 86957325..5b84d058 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -30,13 +30,18 @@ export function toObserver( export type WatchedEffect = () => (() => void) | void | undefined interface WatchableNode extends ReactiveNode { + /** + * Reference count: number of direct subs that are alive + * When >0, the node is "alive" and its watch effects should be running + * When =0, the node is "dead", and its watch effects should be stopped + */ _watches?: number _watchEffects?: Array _watchCleanups?: Array<(() => void) | void | undefined> } -function getWatchCount(node: WatchableNode): number { - return node._watches || (node.flags & ReactiveFlags.Watching) ? 1 : 0 +function isWatched(node: WatchableNode): boolean { + return !!node._watches } function addWatch(node: WatchableNode) { @@ -54,7 +59,7 @@ function addWatch(node: WatchableNode) { deps = deps.nextDep } - // 2. start/run watch effects + // 2. start/run own watch effects const watchEffects = node._watchEffects if (watchEffects?.length) { node._watchCleanups = watchEffects.map((ef) => ef()) @@ -82,7 +87,11 @@ function removeWatch(node: WatchableNode) { } } -export function whileWatched(node: WatchableNode, fn: WatchedEffect) { +/** + * Causes `fn` to be called when `node` becomes gains its first live subscriber. + * If `fn` returns a cleanup function, it will be called when `node` loses its last live subscriber. + */ +export function whiteWatched(node: WatchableNode, fn: WatchedEffect) { const initialEffects = (node._watchEffects ??= []) initialEffects.push(fn) if (node._watches) { @@ -157,14 +166,6 @@ const { link: _link, unlink: _unlink, propagate, checkDirty, shallowPropagate } effect.flags &= ~ReactiveFlags.Watching }, unwatched(atom: InternalAtom): void { - atom._watched = false - // not sure if this should go above or below the deps purge - // preorder vs postorder - if (atom._watchedCleanups?.length) { - atom._watchedCleanups.forEach((cleanup) => cleanup?.()) - atom._watchedCleanups.length = 0 - } - if (atom.depsTail !== undefined) { atom.depsTail = undefined atom.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty @@ -178,7 +179,7 @@ function link(dep: ReactiveNode, sub: ReactiveNode, version: number) { _link(dep, sub, version) const newTail = dep.subsTail - if (newTail && newTail !== originalTail && getWatchCount(sub)) { + if (newTail && newTail !== originalTail && isWatched(sub)) { // Propagate watch liveness from sub -> dep addWatch(dep) } @@ -191,7 +192,7 @@ function unlink( sub: ReactiveNode = link.sub ): Link | undefined { const dep = link.dep - if (getWatchCount(sub)) { + if (isWatched(sub)) { // Revoke liveness from this sub on dep when unlinked removeWatch(dep) } @@ -374,21 +375,7 @@ export function createAtom( }, whileWatched(listener: WatchedEffect): () => void { - atom._watchedSubs ??= [] - atom._watchedSubs.push(listener) - if (atom._watched) { - atom._watchedCleanups ??= [] - atom._watchedCleanups.push(listener()) - } - return () => { - if (!atom._watchedSubs) { - return - } - const index = atom._watchedSubs.indexOf(listener) - if (index !== -1) { - atom._watchedSubs.splice(index, 1) - } - } + return whiteWatched(this, listener) }, _update(getValue?: T | ((snapshot: T) => T)): boolean { @@ -471,6 +458,7 @@ export function createAtom( } interface Effect extends ReactiveNode { + _watches: number notify: () => void stop: () => void } @@ -496,6 +484,8 @@ function effect(fn: () => T): Effect { subs: undefined, subsTail: undefined, flags: ReactiveFlags.Watching | ReactiveFlags.RecursedCheck, + // Effects are the source of liveness - they are created alive + _watches: 1, notify(): void { const flags = this.flags diff --git a/packages/store/tests/external-store-atom.test.ts b/packages/store/tests/external-store-atom.test.ts index 9b42fa92..0ace2e2c 100644 --- a/packages/store/tests/external-store-atom.test.ts +++ b/packages/store/tests/external-store-atom.test.ts @@ -33,6 +33,29 @@ describe('createExternalStoreAtom', () => { expect(ext.listenerCount()).toBe(0) }) + test('unobserved .get() does not activate the external subscription', () => { + const ext = makeExternalStore(7) + const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + + expect(atom.get()).toBe(7) + expect(ext.listenerCount()).toBe(0) + + atom.get() + atom.get() + expect(ext.listenerCount()).toBe(0) + }) + + test('unobserved chain read through derived atoms does not activate', async () => { + const { createAtom } = await import('../src') + const ext = makeExternalStore(1) + const a = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + const b = createAtom(() => a.get() * 2) + const c = createAtom(() => `${b.get()} dogs`) + + expect(c.get()).toBe('2 dogs') + expect(ext.listenerCount()).toBe(0) + }) + test('subscribes on first atom subscriber; unsubscribes on last', () => { const ext = makeExternalStore(0) const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) From 51975bf06e2267501e644a404026a656a88ec592 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 22 Apr 2026 19:01:11 -0600 Subject: [PATCH 19/23] fix typo of whileWatched name --- packages/store/src/atom.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index 5b84d058..6f873fcc 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -91,7 +91,7 @@ function removeWatch(node: WatchableNode) { * Causes `fn` to be called when `node` becomes gains its first live subscriber. * If `fn` returns a cleanup function, it will be called when `node` loses its last live subscriber. */ -export function whiteWatched(node: WatchableNode, fn: WatchedEffect) { +export function whileWatched(node: WatchableNode, fn: WatchedEffect) { const initialEffects = (node._watchEffects ??= []) initialEffects.push(fn) if (node._watches) { @@ -375,7 +375,7 @@ export function createAtom( }, whileWatched(listener: WatchedEffect): () => void { - return whiteWatched(this, listener) + return whileWatched(this, listener) }, _update(getValue?: T | ((snapshot: T) => T)): boolean { From 7ccd18896f18cd71cfe2c507064221017818c9d6 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 22 Apr 2026 19:17:14 -0600 Subject: [PATCH 20/23] re-entrancy tests & fixes --- packages/store/src/atom.ts | 43 +++-- packages/store/tests/while-watched.test.ts | 187 ++++++++++++++++++++- 2 files changed, 219 insertions(+), 11 deletions(-) diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index 6f873fcc..335f45b8 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -59,10 +59,22 @@ function addWatch(node: WatchableNode) { deps = deps.nextDep } - // 2. start/run own watch effects + // 2. start/run own watch effects. + // Assign `_watchCleanups` BEFORE invoking any effect and seed it with + // packed `undefined` slots (avoid `new Array(len)` which gives a holey + // SMI array in V8). A re-entrant `whileWatched()` during `ef()` pushes + // into this same array at `i >= len`, keeping indices aligned with + // `_watchEffects`; the outer loop writes the pre-reserved slot by index. const watchEffects = node._watchEffects if (watchEffects?.length) { - node._watchCleanups = watchEffects.map((ef) => ef()) + const len = watchEffects.length + const cleanups: Array<(() => void) | void | undefined> = [] + node._watchCleanups = cleanups + for (let i = 0; i < len; i++) cleanups.push(undefined) + for (let i = 0; i < len; i++) { + const ef = watchEffects[i] + if (ef) cleanups[i] = ef() + } } } } @@ -73,17 +85,27 @@ function removeWatch(node: WatchableNode) { // On last unwatch, node becomes dead: if (next === 0) { - // 1. Clean up effects - // We clean up subs before we clean up their deps (no use after free) - node._watchCleanups?.forEach((cleanup) => cleanup?.()) + // 1. Clean up effects. + // Snapshot and clear `_watchCleanups` BEFORE invoking any cleanup, so a + // re-entrant subscribe during cleanup sees a consistent (empty) state and + // can rebuild cleanups into a fresh array via addWatch. + const cleanups = node._watchCleanups node._watchCleanups = undefined + if (cleanups) { + for (let i = cleanups.length - 1; i >= 0; i--) { + cleanups[i]?.() + } + } - // 2. propagate unwatch to deps + // 2. propagate unwatch to deps. + // Capture `prevDep` BEFORE recursing, so cleanups that mutate the dep + // list can't redirect our traversal. let deps = node.depsTail - while (deps !== undefined) { - removeWatch(deps.dep) - deps = deps.prevDep - } + while (deps !== undefined) { + const prev = deps.prevDep + removeWatch(deps.dep) + deps = prev + } } } @@ -503,6 +525,7 @@ function effect(fn: () => T): Effect { this.flags = ReactiveFlags.None this.depsTail = undefined purgeDeps(this) + removeWatch(this) }, } diff --git a/packages/store/tests/while-watched.test.ts b/packages/store/tests/while-watched.test.ts index 40b7e2d5..446bf860 100644 --- a/packages/store/tests/while-watched.test.ts +++ b/packages/store/tests/while-watched.test.ts @@ -1,5 +1,24 @@ import { describe, expect, test, vi } from 'vitest' -import { createAtom } from '../src' +import { createAtom, createExternalStoreAtom } from '../src' + +function makeExternalStore(initial: T) { + let value = initial + const listeners = new Set<() => void>() + return { + getSnapshot: () => value, + set(next: T) { + value = next + listeners.forEach((l) => l()) + }, + subscribe(cb: () => void) { + listeners.add(cb) + return () => { + listeners.delete(cb) + } + }, + listenerCount: () => listeners.size, + } +} describe('whileWatched', () => { test('does not fire before any subscriber', () => { @@ -168,4 +187,170 @@ describe('whileWatched', () => { subDerived.unsubscribe() subSource.unsubscribe() }) + + test('long chain sub/unsub cycles do not drift _watches', () => { + const ext = makeExternalStore(1) + const a = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + const b = createAtom(() => a.get() * 2) + const c = createAtom(() => `${b.get()} dogs`) + + const effect = vi.fn() + const cleanup = vi.fn() + a.whileWatched(() => { + effect() + return cleanup + }) + + for (let i = 0; i < 10; i++) { + const sub = c.subscribe(() => {}) + expect(ext.listenerCount()).toBe(1) + sub.unsubscribe() + expect(ext.listenerCount()).toBe(0) + } + + expect(effect).toHaveBeenCalledTimes(10) + expect(cleanup).toHaveBeenCalledTimes(10) + }) + + test('conditional deps: dropped branch cleanup fires on recompute', () => { + const cond = createAtom(true) + const a = createAtom(1) + const b = createAtom(10) + const pick = createAtom(() => (cond.get() ? a.get() : b.get())) + + const aCleanup = vi.fn() + const bCleanup = vi.fn() + const aEffect = vi.fn(() => aCleanup) + const bEffect = vi.fn(() => bCleanup) + a.whileWatched(aEffect) + b.whileWatched(bEffect) + + const sub = pick.subscribe(() => {}) + expect(aEffect).toHaveBeenCalledTimes(1) + expect(bEffect).not.toHaveBeenCalled() + + cond.set(false) + expect(aCleanup).toHaveBeenCalledTimes(1) + expect(bEffect).toHaveBeenCalledTimes(1) + expect(bCleanup).not.toHaveBeenCalled() + + cond.set(true) + expect(bCleanup).toHaveBeenCalledTimes(1) + expect(aEffect).toHaveBeenCalledTimes(2) + + sub.unsubscribe() + expect(aCleanup).toHaveBeenCalledTimes(2) + expect(bCleanup).toHaveBeenCalledTimes(1) + }) + + test('diamond graph: shared source counted once per activation', () => { + const ext = makeExternalStore(1) + const source = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + const left = createAtom(() => source.get() + 1) + const right = createAtom(() => source.get() + 2) + + const subL = left.subscribe(() => {}) + expect(ext.listenerCount()).toBe(1) + const subR = right.subscribe(() => {}) + expect(ext.listenerCount()).toBe(1) + + subL.unsubscribe() + expect(ext.listenerCount()).toBe(1) + subR.unsubscribe() + expect(ext.listenerCount()).toBe(0) + + // Re-activation cycle works + const subL2 = left.subscribe(() => {}) + expect(ext.listenerCount()).toBe(1) + subL2.unsubscribe() + expect(ext.listenerCount()).toBe(0) + }) + + test('stop() while watched runs cleanup immediately and only once', () => { + const atom = createAtom(0) + const cleanup = vi.fn() + const stop = atom.whileWatched(() => cleanup) + + const sub = atom.subscribe(() => {}) + expect(cleanup).not.toHaveBeenCalled() + + stop() + expect(cleanup).toHaveBeenCalledTimes(1) + + sub.unsubscribe() + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + test('re-entrant subscribe during cleanup leaves graph consistent', () => { + const ext = makeExternalStore(0) + const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + + const activate = vi.fn() + let reenter = true + + atom.whileWatched(() => { + activate() + return () => { + if (reenter) { + reenter = false + // briefly resubscribe during cleanup — activates a second cycle + const innerSub = atom.subscribe(() => {}) + innerSub.unsubscribe() + } + } + }) + + const sub = atom.subscribe(() => {}) + expect(activate).toHaveBeenCalledTimes(1) + expect(ext.listenerCount()).toBe(1) + + sub.unsubscribe() + // The re-entry created a 2nd activation, then cleaned up. + expect(activate).toHaveBeenCalledTimes(2) + // After all unwinding, external store must have zero listeners. + expect(ext.listenerCount()).toBe(0) + + // _watches must not be stuck: a fresh subscribe re-activates. + const sub2 = atom.subscribe(() => {}) + expect(activate).toHaveBeenCalledTimes(3) + expect(ext.listenerCount()).toBe(1) + sub2.unsubscribe() + expect(ext.listenerCount()).toBe(0) + }) + + test('adding a whileWatched during another effect runs it immediately', () => { + const atom = createAtom(0) + const lateEffect = vi.fn() + const lateCleanup = vi.fn() + + atom.whileWatched(() => { + atom.whileWatched(() => { + lateEffect() + return lateCleanup + }) + }) + + const sub = atom.subscribe(() => {}) + expect(lateEffect).toHaveBeenCalledTimes(1) + expect(lateCleanup).not.toHaveBeenCalled() + + sub.unsubscribe() + expect(lateCleanup).toHaveBeenCalledTimes(1) + }) + + test('many subscribers release source exactly when the last one leaves', () => { + const ext = makeExternalStore(0) + const atom = createExternalStoreAtom(ext.getSnapshot, ext.subscribe) + + const subs = Array.from({ length: 20 }, () => atom.subscribe(() => {})) + expect(ext.listenerCount()).toBe(1) + + for (let i = 0; i < subs.length - 1; i++) { + subs[i].unsubscribe() + expect(ext.listenerCount()).toBe(1) + } + + subs[subs.length - 1].unsubscribe() + expect(ext.listenerCount()).toBe(0) + }) }) From bf088729b124da7c25af0136dc6b76e5a1d6d28e Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 22 Apr 2026 19:19:56 -0600 Subject: [PATCH 21/23] Assertion if watched becomes negative --- packages/store/src/atom.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index 335f45b8..b8bdaafe 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -106,6 +106,8 @@ function removeWatch(node: WatchableNode) { removeWatch(deps.dep) deps = prev } + } else if (next < 0) { + throw new Error(`Unbalanced watch/unwatch led to negative watch count on ReactiveNode: ${next}`) } } From 5b3f7b57722763f2aa4db5cb9f507c3a81cfa3ba Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Wed, 22 Apr 2026 19:24:26 -0600 Subject: [PATCH 22/23] cleanup old approach --- packages/store/src/atom.ts | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/packages/store/src/atom.ts b/packages/store/src/atom.ts index b8bdaafe..aebb62aa 100644 --- a/packages/store/src/atom.ts +++ b/packages/store/src/atom.ts @@ -1,4 +1,3 @@ -import { destroyPlatform } from '@angular/core'; import { ReactiveFlags, createReactiveSystem } from './alien' import type { Link, ReactiveNode } from './alien' @@ -158,7 +157,7 @@ export function whileWatched(node: WatchableNode, fn: WatchedEffect) { * Returns a cleanup function that will be called when the atom is unwatched. */ -interface InternalAtom extends ReactiveNode { +interface InternalAtom extends WatchableNode { _snapshot: T _update: (getValue?: T | ((snapshot: T) => T)) => boolean @@ -171,10 +170,6 @@ interface InternalAtom extends ReactiveNode { * Returns a `stop` function which cancels the listener. */ whileWatched: (effect: WatchedEffect) => () => void - - _watched: boolean - _watchedSubs?: Array - _watchedCleanups?: Array<(() => void) | void | undefined> } const queuedEffects: Array = [] @@ -364,8 +359,7 @@ export function createAtom( // Create plain object atom const atom: InternalAtom = { _snapshot: isComputed ? undefined! : valueOrFn, - _watched: false, - + _watches: 0, subs: undefined, subsTail: undefined, deps: undefined, From 13a32c50f06bdf5f365d8aa6b0ae9fbcbdf21a21 Mon Sep 17 00:00:00 2001 From: Jake Teton-Landis Date: Sun, 26 Apr 2026 13:30:09 -0400 Subject: [PATCH 23/23] address nit to verify whileWatched effect lifetime in test --- packages/store/tests/while-watched.test.ts | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/packages/store/tests/while-watched.test.ts b/packages/store/tests/while-watched.test.ts index 446bf860..729db348 100644 --- a/packages/store/tests/while-watched.test.ts +++ b/packages/store/tests/while-watched.test.ts @@ -320,17 +320,24 @@ describe('whileWatched', () => { test('adding a whileWatched during another effect runs it immediately', () => { const atom = createAtom(0) + const outerEffect = vi.fn() const lateEffect = vi.fn() const lateCleanup = vi.fn() atom.whileWatched(() => { + outerEffect() atom.whileWatched(() => { lateEffect() return lateCleanup }) }) + // whileWatched should not have started the effect immediately + expect(outerEffect).not.toHaveBeenCalled() + expect(lateEffect).not.toHaveBeenCalled() + const sub = atom.subscribe(() => {}) + expect(outerEffect).toHaveBeenCalledTimes(1) expect(lateEffect).toHaveBeenCalledTimes(1) expect(lateCleanup).not.toHaveBeenCalled()