Skip to content

[@xstate/store] Fix synchronous updates through nested atom dependencies and subscriptions#5518

Merged
davidkpiano merged 4 commits intostatelyai:mainfrom
thecrypticace:fix/subscribe-updates-pending
May 7, 2026
Merged

[@xstate/store] Fix synchronous updates through nested atom dependencies and subscriptions#5518
davidkpiano merged 4 commits intostatelyai:mainfrom
thecrypticace:fix/subscribe-updates-pending

Conversation

@thecrypticace
Copy link
Copy Markdown
Contributor

Fix synchronous subscribe callbacks still not re-running if a subscription triggers another one through multiple levels of computed atoms.


Turns out checking for the dirty state wasn't enough — I needed to check for the pending state as well.

Essentially the following test currently fails but now passes after this PR:

it('allows updates to a computed dependency during a subscription callback', () => {
  const atom = createAtom({ a: 0, b: 0, c: 0 });

  const a0 = createAtom(() => atom.get().a);
  const a1 = createAtom(() => a0.get());
  a1.subscribe(() => atom.set((ctx) => ({ ...ctx, b: ctx.a })));

  const b0 = createAtom(() => atom.get().b);
  const b1 = createAtom(() => b0.get());
  b1.subscribe(() => atom.set((ctx) => ({ ...ctx, c: ctx.b })));

  atom.set((ctx) => ({ ...ctx, a: ctx.a + 1 }));
  atom.set((ctx) => ({ ...ctx, a: ctx.a + 1 }));
  atom.set((ctx) => ({ ...ctx, a: ctx.a + 1 }));

  expect(a0.get()).toBe(3);
  expect(a1.get()).toBe(3);
  expect(b0.get()).toBe(3);
  expect(b1.get()).toBe(3);
  expect(atom.get().a).toBe(3);
  expect(atom.get().b).toBe(3);
  expect(atom.get().c).toBe(3);
});

This happens because b1's subscription callback runs during a1's subscription callback (which the previous PR handled) but the state of the atom is different in this particular case.

Before submitting the original PR I tried to determine if a `Pending` check was necessary. Apparently not well enough. It is indeed but the situation requires subscriptions to be fired synchronously within a subscription. Multiple atoms can be used to acheive this.
@changeset-bot
Copy link
Copy Markdown

changeset-bot Bot commented May 6, 2026

🦋 Changeset detected

Latest commit: 10eb104

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 7 packages
Name Type
@xstate/store Patch
@xstate/store-angular Patch
@xstate/store-preact Patch
@xstate/store-react Patch
@xstate/store-solid Patch
@xstate/store-svelte Patch
@xstate/store-vue Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

Comment thread packages/xstate-store/src/atom.ts Outdated
if (atom.flags & ReactiveFlags.Dirty) {
if (atom.flags & (ReactiveFlags.Dirty | ReactiveFlags.Pending)) {
atom.get();
}
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

since atom.get() does an if check already it's probably okay to just drop this if entirely? Thoughts?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Give it a try; if the tests pass I'd say it's safe

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

They do 👍

The `.get()` call already does the necessary dirty checks so we can unconditionally call it
@davidkpiano davidkpiano merged commit 3a881f5 into statelyai:main May 7, 2026
1 check passed
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.

2 participants