Skip to content

fix: ObservableSet.replace only emits events for actual changes#4672

Open
chatman-media wants to merge 2 commits into
mobxjs:mainfrom
chatman-media:fix/observable-set-replace-events
Open

fix: ObservableSet.replace only emits events for actual changes#4672
chatman-media wants to merge 2 commits into
mobxjs:mainfrom
chatman-media:fix/observable-set-replace-events

Conversation

@chatman-media

Copy link
Copy Markdown

Closes #3761

Problem

ObservableSet.replace(...) was implemented as clear() followed by re-adding every value of the source. As a result it always emitted a delete event for every existing element and an add event for every replacement element — even for elements that were unchanged — and unnecessarily retriggered reactions.

import { observable, configure } from "mobx"
configure({ enforceActions: "never" })

const s = observable.set(["a", "b", "c"])
s.observe(console.log)

// Before: delete a, delete b, delete c, add b, add c, add d  (6 events)
// After:  delete a, add d                                     (2 events)
s.replace(["b", "c", "d"])

// Before: delete x, delete y, add x, add y  (4 events)
// After:  (no events)
observable.set(["x", "y"]).replace(["x", "y"])

This mirrors the long-standing complaint that ObservableMap.replace already handles correctly (see #1243 / #1980 in the map tests).

Fix

replace now:

  • deletes only the values that are not part of the replacement, and
  • adds only the values from the replacement (which are no-ops when already present).

add/delete already short-circuit (and emit no event / no reportChanged) when a value is respectively already present or already absent, so this is sufficient to make replace emit events only for genuine changes. The approach matches ObservableMap.replace.

Tests

Added a #3761 describe block in set.js covering:

  • replace with array / ES6 Set / observable Set only emits events for removed & added values
  • replace with identical content emits no events and does not report a change to reactions
  • interceptors are still honored during replace

These tests fail on main and pass with the fix. The existing set crud test (which exercises replace with disjoint sets) still passes unchanged. Added a changeset (patch).

ObservableSet.replace previously cleared the set and re-added every
value, emitting a delete event for every existing element followed by
an add event for every replacement element - even when the contents
were unchanged. This also retriggered reactions unnecessarily.

It now deletes only the values that are not part of the replacement and
adds only the new ones (add/delete are already no-ops for unchanged
values), mirroring the behavior of ObservableMap.replace.

Closes mobxjs#3761
@changeset-bot

changeset-bot Bot commented Jun 13, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 57c170d

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

This PR includes changesets to release 1 package
Name Type
mobx 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

@mweststrate mweststrate left a comment

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.

Thanks for making this improvement! I left some notes how the implementation can be slightly improved, but overall looks solid to me!

// `delete` are already no-ops for values that are respectively already
// present or already absent, so we just need to avoid deleting values that
// are part of the replacement. See #3761.
transaction(() => {

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.

This generally looks great! Note that this seems to be making a semantic change:

  • Sets to preserve insertion order (per https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Set#description), the old behavior would preserve the insertion order of the new set (e.g. {a,b,c}.replace({d,b,a}) would result in a set that is iterated like d,b,a. With this change, the iteration would become a,b,d. Now I think a good argument could be made that is semantically more accurate (and thus a bug fix), but it is a observable change of behavior, so we should mention it clearly in the release notes
  • The new behavior now does to two iterations (first loop the old set to delete items, then loop the new set to add items), so this is likely a bit slower. I think this is fine. But it would be great to short-circuit the scenario where either the this or replacement is empty.

// are part of the replacement. See #3761.
transaction(() => {
// Collect the desired values for quick lookup (dedupes the source).
const replacementValues = new Set<T>(other as Iterable<T>)

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.

if other is already a Set, let's not allocate another one since it isn't mutated anyway.

Comment thread packages/mobx/__tests__/base/set.js Outdated
events.push(change)
})

s.replace(["b", "c", "d"])

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.

if you'd reorder the items in this array it would verify the change in behavior is discussed below

Comment thread packages/mobx/__tests__/base/set.js Outdated
events.push(change)
})

s.replace(new Set([2, 3, 4]))

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.

Same

Per review on mobxjs#3761:

- Reuse `other` directly when it is already a Set instead of allocating a
  second one (observable sets are already snapshotted earlier and the Set is
  only read, never mutated).
- Short-circuit the trivial cases: an empty replacement is a plain `clear()`,
  and replacing into an empty set only needs the adds.
- Document the (observable) iteration-order change in the changeset: surviving
  values now keep their original position and new values are appended rather
  than the set being reordered to match the argument.
- Reorder the replacement arrays in the tests so they assert the resulting
  iteration order and cover the documented behavior change.
@chatman-media chatman-media force-pushed the fix/observable-set-replace-events branch from 176d5f2 to 57c170d Compare June 21, 2026 13:07
@chatman-media

Copy link
Copy Markdown
Author

Thanks for the thorough review! Addressed the notes in 57c170d:

  • Iteration order — agreed this is an observable change (and, as you say, arguably the more correct one). I did not try to preserve the old ordering; surviving values keep their original position and new values are appended. Documented this clearly in the changeset, with the {a,b,c}.replace({d,b,a}) -> a,b,d example.
  • Two iterations / short-circuit — added fast paths: an empty replacement is just a clear(), and replacing into an empty set only runs the adds, so the common trivial cases skip the second loop entirely.
  • Avoid the extra Set allocation — when other is already a Set it is now reused directly for the membership checks (observable sets are snapshotted earlier, and the Set is only read, never mutated). Arrays are still wrapped so they get deduped.
  • Tests — reordered the array and ES6-Set replacements so they assert the resulting iteration order, which exercises the documented behavior change. The existing set crud test still passes unchanged.

@chatman-media

chatman-media commented Jun 24, 2026

Copy link
Copy Markdown
Author

Thanks for the review, @mweststrate. Addressed in 57c170d:

  • documented the observable iteration-order change in the changeset
  • reused existing Set inputs instead of allocating another Set
  • added fast paths for empty replacement / empty current set
  • updated the tests to lock in the new order semantics

Full set.js suite is green locally. Happy to adjust the order semantics if you'd prefer matching the replacement order instead.

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.

Calling "replace" on an ObservableSet always changes events

2 participants