Skip to content

feat(core) atom.whileWatched (useEffect-like primitive) and createExternalStoreAtom#312

Open
justjake wants to merge 16 commits intoTanStack:mainfrom
justjake:jake--whileWatched
Open

feat(core) atom.whileWatched (useEffect-like primitive) and createExternalStoreAtom#312
justjake wants to merge 16 commits intoTanStack:mainfrom
justjake:jake--whileWatched

Conversation

@justjake
Copy link
Copy Markdown

@justjake justjake commented Apr 17, 2026

🎯 Changes

Add atom.whileWatched(cb), store.whileWatched(cb), and createExternaStoreAtom.

atom.whileWatched allows running a setup function when the atom gains its first subscriber, and optionally running the cleanup function returned by setup when the atom loses its last subscriber.

This gives us a useLayoutEffect-like primitive that we build createExternalStoreAtom on top of.

Example use case of these features is to implement a createQueryAtom function to pull @tanstack/query-core state into the @tanstack/store reactivity graph. We use this primitive at Notion Labs Inc for this purpose.

You can also use this primitive to build atoms that automatically sync their state across tabs via localStorage, etc

import { type QueryClient, type QueryObserverOptions, QueryObserver } from '@tanstack/query-core'
import { createExternalStoreAtom } from '@tanstack/store'
import { queryClient } from "./queryClient"

export function createQueryAtom<TData, TError = Error>(
  queryClient: QueryClient,
  options: QueryObserverOptions<TData, TError, TData, TData, readonly unknown[]>,
) {
  const observer = new QueryObserver<TData, TError>(queryClient, options)
  return createExternalStoreAtom(
    () => observer.getCurrentResult(),
    (onChange) => observer.subscribe(onChange),
  )
}

// Example
const userQuery = createQueryAtom<{ id: number; name: string }>(queryClient, {
  queryKey: ['user', 1],
  queryFn: async () => {
    const res = await fetch('/api/user/1')
    return res.json()
  },
})

// No fetch yet — nobody's observing.

const sub = userQuery.subscribe((result) => {
  if (result.isPending) console.log('loading…')
  else if (result.error) console.error('failed:', result.error)
  else console.log('user:', result.data)
})
// First subscribe → whileWatched fires → observer.subscribe → fetch kicks off.

// Read the current state any time, even synchronously from elsewhere:
console.log(userQuery.get().data)

// Later…
sub.unsubscribe()
// Last unsubscriber → observer.unsubscribe runs → after gcTime, query is evicted.

✅ Checklist

  • I have followed the steps in the Contributing guide.
  • I have tested this code locally with pnpm test:pr.

🚀 Release Impact

  • This change affects published code, and I have generated a changeset.
  • This change is docs/CI/dev-only (no release).

Summary by CodeRabbit

  • New Features

    • Added whileWatched() to atoms and stores to run effects while actively observed, with cleanup when unwatched.
    • Added createExternalStoreAtom() to mirror external store state as a reactive atom with lazy subscription and shared teardown.
  • Documentation

    • Added API docs and navigation entries for WatchedEffect and createExternalStoreAtom; updated references.
  • Tests

    • Added end-to-end tests covering whileWatched behavior and external-store atom integration.

@justjake justjake changed the title atom.whileWatched (useEffect-like primitive) and createExternalStoreAtom feat(core) atom.whileWatched (useEffect-like primitive) and createExternalStoreAtom Apr 17, 2026
@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Apr 17, 2026

📝 Walkthrough

Walkthrough

Adds a "while watched" lifecycle to atoms and stores and a new createExternalStoreAtom helper; integrates per-atom watch tracking into the reactive system; updates types, store API, docs, and tests to exercise subscription, teardown, and deduplication behavior.

Changes

Cohort / File(s) Summary
Release & Docs
.changeset/ninety-actors-hide.md, docs/config.json
New changeset bumping store packages; docs navigation entries added for WatchedEffect and createExternalStoreAtom.
Reactive System Core
packages/store/src/alien.ts
createReactiveSystem gains a watched(sub) callback and invokes it when a node gains its first subscriber.
Atom Implementation
packages/store/src/atom.ts
Adds exported WatchedEffect type, per-atom _watched state, _watchedSubs and _watchedCleanups, atom.whileWatched(effect) method, createExternalStoreAtom(getSnapshot, subscribe, options) factory, and integrates watched/unwatched lifecycle handling.
Store API
packages/store/src/store.ts
Adds whileWatched(effect) to Store and ReadonlyStore, delegating to the underlying atom.
Types
packages/store/src/types.ts
Extends BaseAtom<T> with whileWatched(effect: WatchedEffect): () => void and imports WatchedEffect.
Signal System Stub
packages/store/src/signal.ts
Adds a no-op watched(_node) handler (comment: whileWatched not implemented for signals).
Tests
packages/store/tests/while-watched.test.ts, packages/store/tests/external-store-atom.test.ts
New tests covering whileWatched lifecycle, reference-counting, derived-atom interactions, and createExternalStoreAtom subscription/teardown, deduplication, and interoperability.
Docs
docs/reference/..., docs/reference/functions/createExternalStoreAtom.md, docs/reference/type-aliases/WatchedEffect.md, docs/reference/index.md
Documentation pages and indices updated/added: new WatchedEffect alias and createExternalStoreAtom function docs; multiple "Defined in" source line reference updates reflecting code shifts; whileWatched documented across Store/Atom interfaces.

Sequence Diagram

sequenceDiagram
    participant Subscriber
    participant DerivedAtom as Derived Atom
    participant TriggerAtom as Trigger Atom
    participant ExternalStore as External Store

    Subscriber->>DerivedAtom: subscribe()
    DerivedAtom->>TriggerAtom: depend (derived reads trigger)
    TriggerAtom->>TriggerAtom: watched() invoked -> run _watchedSubs
    TriggerAtom->>ExternalStore: subscribe(invalidate) (via whileWatched)

    ExternalStore->>TriggerAtom: invalidate() (on external change)
    TriggerAtom->>DerivedAtom: notify dependency changed
    DerivedAtom->>ExternalStore: getSnapshot()
    DerivedAtom->>Subscriber: deliver new value

    Subscriber->>DerivedAtom: unsubscribe()
    DerivedAtom->>TriggerAtom: last subscriber removed (unwatched)
    TriggerAtom->>TriggerAtom: unwatched() invoked -> run _watchedCleanups
    TriggerAtom->>ExternalStore: call unsubscribe()
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Poem

🐰 I watch the watchers, snug and bright,

I wake to listen, sleep when light.
External fields only hum when shown,
I tidy up when all have flown.
Small hops, soft upkeep — homegrown delight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main changes: the addition of atom.whileWatched and createExternalStoreAtom APIs.
Description check ✅ Passed The description comprehensively covers all required sections from the template with concrete details about the changes, rationale, and real-world use cases.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 7

🧹 Nitpick comments (1)
packages/store/tests/while-watched.test.ts (1)

103-124: Add a stop()-while-watched test case.

Current stop() tests cover idempotency and future activations, but not cancellation during an active watch. Add a case where stop() is called before unsubscribing and assert cleanup runs immediately and only once.

Suggested test addition
 test('stop() is idempotent', () => {
   const atom = createAtom(0)
   const stop = atom.whileWatched(() => {})
   expect(() => {
     stop()
     stop()
   }).not.toThrow()
 })
+
+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(() => {})
+
+  stop()
+  expect(cleanup).toHaveBeenCalledTimes(1)
+
+  sub.unsubscribe()
+  expect(cleanup).toHaveBeenCalledTimes(1)
+})
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/store/tests/while-watched.test.ts` around lines 103 - 124, Add a new
test to verify that calling stop() during an active watch cancels immediately
and runs cleanup exactly once: create an atom via createAtom(0), call
atom.whileWatched(effect) to get stop, ensure the effect was invoked once after
subscribing (use atom.subscribe(() => {}) and keep the subscription active),
then call stop() before unsubscribing and assert the cleanup/effect-run count
increments exactly once and that subsequent unsubscribe does not call the
cleanup again; reference the whileWatched method and the returned stop function
and use a vi.fn() for effect to assert exact call counts.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@docs/reference/functions/createExternalStoreAtom.md`:
- Line 15: The "Defined in" link for createExternalStoreAtom points to a fork
URL; update the link in docs/reference/functions/createExternalStoreAtom.md (the
"Defined in" line) to use the canonical main repository path pointing to atom.ts
at the correct location (referenced symbol: createExternalStoreAtom /
atom.ts:190) so the documentation links to the upstream repo instead of the
fork.
- Around line 17-36: Fix the markdown by closing the open code fence for the
example and correcting the typo: change "Thic" to "This" in the description for
createExternalStoreAtom and add a terminating triple backtick (```) immediately
before the "## Type Parameters" heading so the example block ends properly.

In `@docs/reference/type-aliases/WatchedEffect.md`:
- Line 12: Update the "Defined in" link in
docs/reference/type-aliases/WatchedEffect.md so it points to the project's
canonical repository URL instead of the fork; locate the "Defined in:
[atom.ts:33](...)" line and replace the current forked GitHub URL with the main
repo URL that matches the package source (referencing atom.ts line 33) so future
readers get the authoritative source reference.
- Around line 9-10: The documented type signature for WatchedEffect is wrong:
update the rendered signature to indicate the callback may return either a
cleanup function (() => void), void, or undefined (i.e., (() => void) | void |
undefined) so it doesn't imply a function is always returned; change the
WatchedEffect documentation and any other occurrences (notably the similar
entries around lines 17-19) to use that corrected union form and ensure the
prose matches this behavior.

In `@packages/store/src/atom.ts`:
- Around line 171-173: The JSDoc for the exported API describing the "pulls
external state into an atom" behavior has a typo ("Thic"); update the comment
text to read "This can be used..." in the JSDoc block that documents the
external-state-to-atom utility (the comment above the function that implements
the useSyncExternalStore-like atom behavior) so the public docs are correct.
- Around line 265-273: stop() currently only removes the listener from
atom._watchedSubs but does not invoke any active cleanup when atom._watched is
true; update stop() so that when removing the listener it also runs and clears
the listener's active cleanup (e.g. call listener._cleanup() or
listener.cleanup() if present and then delete that property), and if removing
the last watched subscriber set atom._watched to false and invoke/clear any
atom-level cleanup (atom._cleanup) as needed; reference the stop() function,
atom._watchedSubs, atom._watched, listener._cleanup / listener.cleanup, and
atom._cleanup to locate and implement these changes.

In `@packages/store/src/types.ts`:
- Line 40: Fix the minor grammar in the API doc comment for the return value of
the whileWatched-related type by changing "an `cleanup` function" to "a
`cleanup` function"; update the doc comment that describes the return value for
the whileWatched listener (the text mentioning `cleanup`) so it reads "a
`cleanup` function" instead of "an `cleanup`".

---

Nitpick comments:
In `@packages/store/tests/while-watched.test.ts`:
- Around line 103-124: Add a new test to verify that calling stop() during an
active watch cancels immediately and runs cleanup exactly once: create an atom
via createAtom(0), call atom.whileWatched(effect) to get stop, ensure the effect
was invoked once after subscribing (use atom.subscribe(() => {}) and keep the
subscription active), then call stop() before unsubscribing and assert the
cleanup/effect-run count increments exactly once and that subsequent unsubscribe
does not call the cleanup again; reference the whileWatched method and the
returned stop function and use a vi.fn() for effect to assert exact call counts.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: c7ec2563-56c3-4ec7-a40b-106287ae95e8

📥 Commits

Reviewing files that changed from the base of the PR and between 83e2978 and 5e144d1.

📒 Files selected for processing (11)
  • .changeset/ninety-actors-hide.md
  • docs/config.json
  • docs/reference/functions/createExternalStoreAtom.md
  • docs/reference/type-aliases/WatchedEffect.md
  • packages/store/src/alien.ts
  • packages/store/src/atom.ts
  • packages/store/src/signal.ts
  • packages/store/src/store.ts
  • packages/store/src/types.ts
  • packages/store/tests/external-store-atom.test.ts
  • packages/store/tests/while-watched.test.ts

Comment thread docs/reference/functions/createExternalStoreAtom.md Outdated
Comment thread docs/reference/functions/createExternalStoreAtom.md
Comment on lines +9 to +10
type WatchedEffect = () => () => void | void | undefined;
```
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Type signature rendering is inaccurate.

The doc currently implies the callback always returns a function. It should reflect (() => void) | void | undefined.

🛠️ Proposed signature/returns fix
-type WatchedEffect = () => () => void | void | undefined;
+type WatchedEffect = () => (() => void) | void | undefined;
...
-() => `void` \| `void` \| `undefined`
+(() => `void`) \| `void` \| `undefined`

Also applies to: 17-19

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/type-aliases/WatchedEffect.md` around lines 9 - 10, The
documented type signature for WatchedEffect is wrong: update the rendered
signature to indicate the callback may return either a cleanup function (() =>
void), void, or undefined (i.e., (() => void) | void | undefined) so it doesn't
imply a function is always returned; change the WatchedEffect documentation and
any other occurrences (notably the similar entries around lines 17-19) to use
that corrected union form and ensure the prose matches this behavior.

Comment thread docs/reference/type-aliases/WatchedEffect.md Outdated
Comment thread packages/store/src/atom.ts
Comment on lines +265 to +273
return () => {
if (!atom._watchedSubs) {
return
}
const index = atom._watchedSubs.indexOf(listener)
if (index !== -1) {
atom._watchedSubs.splice(index, 1)
}
}
Copy link
Copy Markdown

@coderabbitai coderabbitai bot Apr 17, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

stop() does not clean up active watchers, causing leaks while still watched.

On Line 265–Line 273, stop() removes the listener from _watchedSubs but does not run/remove its active cleanup when _watched === true. This can leave resources (e.g. external subscriptions) active until full unwatch.

Proposed fix
     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)
+          if (atom._watched && atom._watchedCleanups) {
+            const [cleanup] = atom._watchedCleanups.splice(index, 1)
+            cleanup?.()
+          }
         }
       }
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/store/src/atom.ts` around lines 265 - 273, stop() currently only
removes the listener from atom._watchedSubs but does not invoke any active
cleanup when atom._watched is true; update stop() so that when removing the
listener it also runs and clears the listener's active cleanup (e.g. call
listener._cleanup() or listener.cleanup() if present and then delete that
property), and if removing the last watched subscriber set atom._watched to
false and invoke/clear any atom-level cleanup (atom._cleanup) as needed;
reference the stop() function, atom._watchedSubs, atom._watched,
listener._cleanup / listener.cleanup, and atom._cleanup to locate and implement
these changes.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It would be a bug to remove / fire the teardown function when removing the setup subscriber.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@justjake, understood — thank you for the clarification. The design intent is that stop() only de-registers the effect from future watch cycles; the current cycle's cleanup still lives in _watchedCleanups and fires naturally when the atom loses its last subscriber via the unwatched hook. Firing the teardown eagerly in stop() would incorrectly cancel a still-active subscription mid-watch. I'll retract the suggestion.

(◕‿◕✿)


✏️ Learnings added
Learnt from: justjake
Repo: TanStack/store PR: 312
File: packages/store/src/atom.ts:265-273
Timestamp: 2026-04-17T17:42:08.842Z
Learning: In `packages/store/src/atom.ts` (TanStack Store), the `stop()` function returned by `atom.whileWatched(listener)` is intentionally designed to only remove the listener from `_watchedSubs` (preventing future re-registration) without firing or removing the active cleanup from `_watchedCleanups`. The current cycle's cleanup remains in `_watchedCleanups` and is called naturally when the atom becomes unwatched (via the `unwatched` hook calling `_watchedCleanups.forEach`). Eagerly running the cleanup in `stop()` would be a bug, as the subscription should stay active for the duration of the current watched period.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread packages/store/src/types.ts Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
packages/store/src/atom.ts (1)

258-273: ⚠️ Potential issue | 🟠 Major

Stopping an active whileWatched listener still leaves its cleanup running.

When stop() is called after Line 263 has registered a cleanup, Lines 269-271 remove the listener from _watchedSubs but never remove or invoke the matching entry in _watchedCleanups. The external resource stays live until full unwatched(), and the cleanup array can drift out of index alignment with the listener array.

Proposed fix
     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)
+          if (atom._watched && atom._watchedCleanups) {
+            const [cleanup] = atom._watchedCleanups.splice(index, 1)
+            cleanup?.()
+          }
         }
       }
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/store/src/atom.ts` around lines 258 - 273, The unsubscribe returned
by whileWatched currently removes the listener from atom._watchedSubs but leaves
the corresponding entry in atom._watchedCleanups, causing the cleanup to keep
running and arrays to drift; update the returned function in whileWatched to
locate the listener index, remove the same index from atom._watchedCleanups (if
present), and invoke the stored cleanup function (or safely skip if undefined)
so the external resource is released immediately; ensure this logic references
atom._watchedSubs, atom._watchedCleanups, and the listener returned by
whileWatched and preserves behavior when atom._watched is false or when
unwatched() later runs.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/store/src/atom.ts`:
- Around line 195-203: The computed atom is calling trigger.get() inside its
read which creates a persistent dependency (trigger -> atom) and keeps
subscribe(invalidate) active; change the implementation so the computed read
does not perform a subscribing get on trigger. Replace the subscribing read
(trigger.get()) with a non-subscribing peek/read (or an equivalent API) so the
computed returns getSnapshot() without creating the dependency, or move the
subscribe(invalidate) logic into the returned atom's whileWatched handler (use
trigger.whileWatched / the atom's whileWatched) so subscription to the external
store is only established when the returned atom itself is observed; reference
trigger, invalidate, trigger.whileWatched, createAtom, and getSnapshot when
making the change.

---

Duplicate comments:
In `@packages/store/src/atom.ts`:
- Around line 258-273: The unsubscribe returned by whileWatched currently
removes the listener from atom._watchedSubs but leaves the corresponding entry
in atom._watchedCleanups, causing the cleanup to keep running and arrays to
drift; update the returned function in whileWatched to locate the listener
index, remove the same index from atom._watchedCleanups (if present), and invoke
the stored cleanup function (or safely skip if undefined) so the external
resource is released immediately; ensure this logic references
atom._watchedSubs, atom._watchedCleanups, and the listener returned by
whileWatched and preserves behavior when atom._watched is false or when
unwatched() later runs.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 205a6861-ba8c-4c99-a55a-3827fb328d5b

📥 Commits

Reviewing files that changed from the base of the PR and between 5e144d1 and 1b5acd1.

📒 Files selected for processing (2)
  • packages/store/src/atom.ts
  • packages/store/src/types.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/store/src/types.ts

Comment on lines +195 to +203
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)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Plain get() can keep the external store subscribed indefinitely.

Line 201 tracks trigger from inside a computed atom. Because computed reads run with activeSub = atom, a one-off atom.get() still creates a trigger -> atom dependency, and packages/store/src/alien.ts calls watched(dep) on that first link. Since that link is only removed when the dependency graph is later purged, Line 197 can leave subscribe(invalidate) active even when the external atom has no subscribers. That breaks the “subscribe only while observed” contract.

Consider avoiding trigger.get() for unobserved reads, or backing this with a mutable atom that reads getSnapshot() on demand and only subscribes via atom.whileWatched.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/store/src/atom.ts` around lines 195 - 203, The computed atom is
calling trigger.get() inside its read which creates a persistent dependency
(trigger -> atom) and keeps subscribe(invalidate) active; change the
implementation so the computed read does not perform a subscribing get on
trigger. Replace the subscribing read (trigger.get()) with a non-subscribing
peek/read (or an equivalent API) so the computed returns getSnapshot() without
creating the dependency, or move the subscribe(invalidate) logic into the
returned atom's whileWatched handler (use trigger.whileWatched / the atom's
whileWatched) so subscription to the external store is only established when the
returned atom itself is observed; reference trigger, invalidate,
trigger.whileWatched, createAtom, and getSnapshot when making the change.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

♻️ Duplicate comments (1)
packages/store/src/atom.ts (1)

196-203: ⚠️ Potential issue | 🟠 Major

Avoid subscribing the external store on plain get().

Line 202 still creates the trigger -> atom dependency during computed evaluation. A one-off atom.get() can therefore mark trigger as watched and keep subscribe(invalidate) active even though the returned atom has no subscribers, which breaks the “only while watched” contract.

Move the subscription to the returned atom
 export function createExternalStoreAtom<T>(
   getSnapshot: () => T,
   subscribe: (onStoreChange: () => void) => () => void,
   options?: AtomOptions<T>,
 ): ReadonlyAtom<T> {
   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)
+  atom.whileWatched(() => subscribe(invalidate))
+  return atom
 }
🧹 Nitpick comments (1)
packages/store/src/atom.ts (1)

41-47: Clarify that stop() only affects future watch cycles.

“Returns a stop function which cancels the listener” reads like immediate teardown, but the implementation intentionally keeps the current cycle active and only prevents future re-registration. Tightening the wording here would make the public contract much clearer.

Based on learnings: In packages/store/src/atom.ts, stop() is intentionally limited to removing the listener from future watch cycles and must not fire or remove the current cycle’s cleanup.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/store/src/atom.ts` around lines 41 - 47, The docstring for
whileWatched (type: (effect: WatchedEffect) => () => void) is misleading: change
the return description to state that the returned stop() only prevents the
provided effect from being re-registered in future watch cycles and does not
invoke the current cycle’s cleanup or remove the active listener immediately;
reference the whileWatched signature and WatchedEffect type, and explicitly note
that existing active effect cleanup will still run only when the current watch
cycle ends.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/store/src/atom.ts`:
- Around line 259-273: The stop closure removes the first matching listener via
indexOf, which breaks when the same listener was registered multiple times;
change the registration to capture the cleanup return and have the stopper
remove the most recently added matching entries using lastIndexOf: when pushing
to atom._watchedSubs, capture the pushed listener (or its index) and when you
push the cleanup (listener()) capture that cleanup value into a local (e.g.,
cleanupRef), then in the returned stop() use
atom._watchedSubs.lastIndexOf(listener) to find and splice the correct (most
recent) subscriber and use atom._watchedCleanups.lastIndexOf(cleanupRef) to
remove the corresponding cleanup entry. Ensure you only attempt to remove when
the found index !== -1.
- Around line 66-79: The watched/unwatched lifecycle must be made
exception-safe: in watched(InternalAtom) initialize atom._watchedCleanups = []
before running any setup and then iterate atom._watchedSubs (not using map)
calling each setup in a try block and pushing its returned cleanup (if any) into
atom._watchedCleanups; if a setup throws, ensure atom._watchedCleanups remains
set with already-collected cleanup functions and rethrow the error. In
unwatched(InternalAtom) iterate atom._watchedCleanups and invoke each cleanup
inside its own try/catch so one throwing cleanup does not prevent others from
running, then always clear atom._watchedCleanups.length = 0 and set
atom._watched = false.

---

Nitpick comments:
In `@packages/store/src/atom.ts`:
- Around line 41-47: The docstring for whileWatched (type: (effect:
WatchedEffect) => () => void) is misleading: change the return description to
state that the returned stop() only prevents the provided effect from being
re-registered in future watch cycles and does not invoke the current cycle’s
cleanup or remove the active listener immediately; reference the whileWatched
signature and WatchedEffect type, and explicitly note that existing active
effect cleanup will still run only when the current watch cycle ends.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: b7e509ae-343a-43a4-812a-b08512fff464

📥 Commits

Reviewing files that changed from the base of the PR and between 1b5acd1 and 6dc28fb.

📒 Files selected for processing (1)
  • packages/store/src/atom.ts

Comment on lines +66 to +79
watched(atom: InternalAtom<any>): void {
atom._watched = true
if (atom._watchedSubs?.length) {
atom._watchedCleanups = atom._watchedSubs.map((sub) => sub())
}
},
unwatched(atom: InternalAtom<any>): 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
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Make watched lifecycle callbacks exception-safe.

Line 69 assigns _watchedCleanups only after every setup succeeds, so one throwing setup loses the cleanups returned by earlier setups. Line 77 also stops on the first throwing cleanup, which skips later teardowns and can leave external subscriptions/resources behind.

Suggested hardening
     watched(atom: InternalAtom<any>): void {
       atom._watched = true
       if (atom._watchedSubs?.length) {
-        atom._watchedCleanups = atom._watchedSubs.map((sub) => sub())
+        const cleanups: Array<(() => void) | void | undefined> = []
+        try {
+          for (const sub of atom._watchedSubs) {
+            cleanups.push(sub())
+          }
+          atom._watchedCleanups = cleanups
+        } catch (error) {
+          atom._watched = false
+          for (const cleanup of cleanups) {
+            cleanup?.()
+          }
+          throw error
+        }
       }
     },
     unwatched(atom: InternalAtom<any>): void {
       atom._watched = false
-      if (atom._watchedCleanups?.length) {
-        atom._watchedCleanups.forEach((cleanup) => cleanup?.())
-        atom._watchedCleanups.length = 0
+      const cleanups = atom._watchedCleanups
+      atom._watchedCleanups = []
+      let firstError: unknown
+      if (cleanups?.length) {
+        for (const cleanup of cleanups) {
+          try {
+            cleanup?.()
+          } catch (error) {
+            firstError ??= error
+          }
+        }
       }
 
       if (atom.depsTail !== undefined) {
         atom.depsTail = undefined
         atom.flags = ReactiveFlags.Mutable | ReactiveFlags.Dirty
         purgeDeps(atom)
       }
+      if (firstError) {
+        throw firstError
+      }
     },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/store/src/atom.ts` around lines 66 - 79, The watched/unwatched
lifecycle must be made exception-safe: in watched(InternalAtom) initialize
atom._watchedCleanups = [] before running any setup and then iterate
atom._watchedSubs (not using map) calling each setup in a try block and pushing
its returned cleanup (if any) into atom._watchedCleanups; if a setup throws,
ensure atom._watchedCleanups remains set with already-collected cleanup
functions and rethrow the error. In unwatched(InternalAtom) iterate
atom._watchedCleanups and invoke each cleanup inside its own try/catch so one
throwing cleanup does not prevent others from running, then always clear
atom._watchedCleanups.length = 0 and set atom._watched = false.

Comment on lines +259 to +273
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)
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

stop() removes the wrong entry for duplicate registrations.

If the same function reference is passed to whileWatched() more than once, both returned stop closures use indexOf(listener) and target the first match. Calling the second stopper first removes the earlier registration instead of its own one, so future watch cycles can keep one listener active unexpectedly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/store/src/atom.ts` around lines 259 - 273, The stop closure removes
the first matching listener via indexOf, which breaks when the same listener was
registered multiple times; change the registration to capture the cleanup return
and have the stopper remove the most recently added matching entries using
lastIndexOf: when pushing to atom._watchedSubs, capture the pushed listener (or
its index) and when you push the cleanup (listener()) capture that cleanup value
into a local (e.g., cleanupRef), then in the returned stop() use
atom._watchedSubs.lastIndexOf(listener) to find and splice the correct (most
recent) subscriber and use atom._watchedCleanups.lastIndexOf(cleanupRef) to
remove the corresponding cleanup entry. Ensure you only attempt to remove when
the found index !== -1.

@KevinVandy KevinVandy requested a review from davidkpiano April 17, 2026 23:11
@nx-cloud
Copy link
Copy Markdown

nx-cloud bot commented Apr 18, 2026

View your CI Pipeline Execution ↗ for commit 768b8b5

Command Status Duration Result
nx affected --targets=test:sherif,test:knip,tes... ⛔ Cancelled 13s View ↗
nx run-many --target=build --exclude=examples/** ✅ Succeeded 11s View ↗

☁️ Nx Cloud last updated this comment at 2026-04-18 17:53:15 UTC

@pkg-pr-new
Copy link
Copy Markdown

pkg-pr-new bot commented Apr 18, 2026

@tanstack/angular-store

npm i https://pkg.pr.new/@tanstack/angular-store@312

@tanstack/preact-store

npm i https://pkg.pr.new/@tanstack/preact-store@312

@tanstack/react-store

npm i https://pkg.pr.new/@tanstack/react-store@312

@tanstack/solid-store

npm i https://pkg.pr.new/@tanstack/solid-store@312

@tanstack/store

npm i https://pkg.pr.new/@tanstack/store@312

@tanstack/svelte-store

npm i https://pkg.pr.new/@tanstack/svelte-store@312

@tanstack/vue-store

npm i https://pkg.pr.new/@tanstack/vue-store@312

commit: 6dc28fb

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (4)
docs/reference/classes/ReadonlyStore.md (2)

136-137: Update terminology to match the documented entity.

The description references "atom" but this is ReadonlyStore documentation. Consider updating to "while the store is watched" for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/classes/ReadonlyStore.md` around lines 136 - 137, The docs
mention "atom" in the ReadonlyStore API description; update the wording to refer
to the documented entity by changing phrases like "while the atom is watched"
and "when the atom is unwatched" to "while the store is watched" and "when the
store is unwatched" (refer to ReadonlyStore and the effect callback) so the
terminology matches the ReadonlyStore documentation.

136-156: Consider using the more detailed description from BaseAtom docs.

The Atom/BaseAtom documentation provides a more precise description that clarifies when the effect runs ("when the first subscriber is added") and includes a helpful example. For consistency and completeness, consider:

  1. Using the detailed description: "effect will be called when the first subscriber is added. effect may return a cleanup function, which will be called when the store is unwatched."
  2. Adding the createTicker example (adapted for stores if appropriate)
  3. Using consistent terminology ("cleanup function" instead of "stop function")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/classes/ReadonlyStore.md` around lines 136 - 156, Update the
ReadonlyStore docs to match BaseAtom wording: change the effect description to
"effect will be called when the first subscriber is added. effect may return a
cleanup function, which will be called when the store is unwatched." Replace the
"stop function" wording with "cleanup function" everywhere in this doc and
ensure the parameter type reference still points to WatchedEffect; additionally
add the adapted createTicker example (from BaseAtom) illustrating effect usage
for stores and showing returning a cleanup function, and keep the Returns
section consistent with the new terminology for the returned cleanup function.
docs/reference/classes/Store.md (2)

170-171: Update terminology to match the documented entity.

The description references "atom" but this is Store documentation. Consider updating to "while the store is watched" for clarity.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/classes/Store.md` around lines 170 - 171, Summary: The docs
use "atom" where they should reference "Store". Update the wording to match the
documented entity by changing references from "atom" to "store" in the sentence
describing effect. Specifically, in the Store documentation entry that mentions
`effect`, replace phrases like "while the atom is watched" and "when the atom is
unwatched" with "while the store is watched" and "when the store is unwatched"
so the terminology matches the `Store` concept and the `effect` description.

170-189: Consider using the more detailed description from BaseAtom docs.

The Atom/BaseAtom documentation provides a more precise description that clarifies when the effect runs ("when the first subscriber is added") and includes a helpful example. For consistency and completeness, consider:

  1. Using the detailed description: "effect will be called when the first subscriber is added. effect may return a cleanup function, which will be called when the store is unwatched."
  2. Adding the createTicker example (adapted for stores if appropriate)
  3. Using consistent terminology ("cleanup function" instead of "stop function")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@docs/reference/classes/Store.md` around lines 170 - 189, Update the
documentation for the Store watch/effect section to match BaseAtom's more
precise wording: say that `effect` (type `WatchedEffect`) "will be called when
the first subscriber is added" and that `effect` may return a "cleanup function"
which is called when the store is unwatched; replace the ambiguous "stop"
wording with "cleanup function" everywhere in this section; also add the adapted
`createTicker` example (or a short store-specific example) showing subscription
start/cleanup to demonstrate lifecycle semantics. Ensure references to `effect`,
`WatchedEffect`, and the example function name (e.g., `createTicker`) are
included so reviewers can locate and verify the changes.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Nitpick comments:
In `@docs/reference/classes/ReadonlyStore.md`:
- Around line 136-137: The docs mention "atom" in the ReadonlyStore API
description; update the wording to refer to the documented entity by changing
phrases like "while the atom is watched" and "when the atom is unwatched" to
"while the store is watched" and "when the store is unwatched" (refer to
ReadonlyStore and the effect callback) so the terminology matches the
ReadonlyStore documentation.
- Around line 136-156: Update the ReadonlyStore docs to match BaseAtom wording:
change the effect description to "effect will be called when the first
subscriber is added. effect may return a cleanup function, which will be called
when the store is unwatched." Replace the "stop function" wording with "cleanup
function" everywhere in this doc and ensure the parameter type reference still
points to WatchedEffect; additionally add the adapted createTicker example (from
BaseAtom) illustrating effect usage for stores and showing returning a cleanup
function, and keep the Returns section consistent with the new terminology for
the returned cleanup function.

In `@docs/reference/classes/Store.md`:
- Around line 170-171: Summary: The docs use "atom" where they should reference
"Store". Update the wording to match the documented entity by changing
references from "atom" to "store" in the sentence describing effect.
Specifically, in the Store documentation entry that mentions `effect`, replace
phrases like "while the atom is watched" and "when the atom is unwatched" with
"while the store is watched" and "when the store is unwatched" so the
terminology matches the `Store` concept and the `effect` description.
- Around line 170-189: Update the documentation for the Store watch/effect
section to match BaseAtom's more precise wording: say that `effect` (type
`WatchedEffect`) "will be called when the first subscriber is added" and that
`effect` may return a "cleanup function" which is called when the store is
unwatched; replace the ambiguous "stop" wording with "cleanup function"
everywhere in this section; also add the adapted `createTicker` example (or a
short store-specific example) showing subscription start/cleanup to demonstrate
lifecycle semantics. Ensure references to `effect`, `WatchedEffect`, and the
example function name (e.g., `createTicker`) are included so reviewers can
locate and verify the changes.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 2809a279-054d-4237-8a98-2fc756a7f36e

📥 Commits

Reviewing files that changed from the base of the PR and between 6dc28fb and 768b8b5.

📒 Files selected for processing (26)
  • docs/reference/classes/ReadonlyStore.md
  • docs/reference/classes/Store.md
  • docs/reference/functions/batch.md
  • docs/reference/functions/createAsyncAtom.md
  • docs/reference/functions/createAtom.md
  • docs/reference/functions/createExternalStoreAtom.md
  • docs/reference/functions/createStore.md
  • docs/reference/functions/flush.md
  • docs/reference/index.md
  • docs/reference/interfaces/Atom.md
  • docs/reference/interfaces/AtomOptions.md
  • docs/reference/interfaces/BaseAtom.md
  • docs/reference/interfaces/InternalBaseAtom.md
  • docs/reference/interfaces/InternalReadonlyAtom.md
  • docs/reference/interfaces/InteropSubscribable.md
  • docs/reference/interfaces/Readable.md
  • docs/reference/interfaces/ReadonlyAtom.md
  • docs/reference/interfaces/Subscribable.md
  • docs/reference/interfaces/Subscription.md
  • docs/reference/type-aliases/AnyAtom.md
  • docs/reference/type-aliases/Observer.md
  • docs/reference/type-aliases/Selection.md
  • docs/reference/type-aliases/StoreAction.md
  • docs/reference/type-aliases/StoreActionMap.md
  • docs/reference/type-aliases/StoreActionsFactory.md
  • docs/reference/type-aliases/WatchedEffect.md
✅ Files skipped from review due to trivial changes (21)
  • docs/reference/type-aliases/Selection.md
  • docs/reference/functions/batch.md
  • docs/reference/type-aliases/StoreActionMap.md
  • docs/reference/type-aliases/StoreAction.md
  • docs/reference/interfaces/AtomOptions.md
  • docs/reference/type-aliases/StoreActionsFactory.md
  • docs/reference/interfaces/Subscription.md
  • docs/reference/interfaces/InteropSubscribable.md
  • docs/reference/functions/createAtom.md
  • docs/reference/type-aliases/WatchedEffect.md
  • docs/reference/interfaces/InternalBaseAtom.md
  • docs/reference/type-aliases/Observer.md
  • docs/reference/interfaces/InternalReadonlyAtom.md
  • docs/reference/index.md
  • docs/reference/functions/createExternalStoreAtom.md
  • docs/reference/functions/createStore.md
  • docs/reference/interfaces/Subscribable.md
  • docs/reference/functions/flush.md
  • docs/reference/functions/createAsyncAtom.md
  • docs/reference/type-aliases/AnyAtom.md
  • docs/reference/interfaces/Readable.md

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant