fix: ObservableSet.replace only emits events for actual changes#4672
fix: ObservableSet.replace only emits events for actual changes#4672chatman-media wants to merge 2 commits into
Conversation
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 detectedLatest commit: 57c170d The changes in this PR will be included in the next version bump. This PR includes changesets to release 1 package
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
left a comment
There was a problem hiding this comment.
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(() => { |
There was a problem hiding this comment.
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 liked,b,a. With this change, the iteration would becomea,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
thisorreplacementis 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>) |
There was a problem hiding this comment.
if other is already a Set, let's not allocate another one since it isn't mutated anyway.
| events.push(change) | ||
| }) | ||
|
|
||
| s.replace(["b", "c", "d"]) |
There was a problem hiding this comment.
if you'd reorder the items in this array it would verify the change in behavior is discussed below
| events.push(change) | ||
| }) | ||
|
|
||
| s.replace(new Set([2, 3, 4])) |
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.
176d5f2 to
57c170d
Compare
|
Thanks for the thorough review! Addressed the notes in 57c170d:
|
|
Thanks for the review, @mweststrate. Addressed in 57c170d:
Full set.js suite is green locally. Happy to adjust the order semantics if you'd prefer matching the replacement order instead. |
Closes #3761
Problem
ObservableSet.replace(...)was implemented asclear()followed by re-adding every value of the source. As a result it always emitted adeleteevent for every existing element and anaddevent for every replacement element — even for elements that were unchanged — and unnecessarily retriggered reactions.This mirrors the long-standing complaint that
ObservableMap.replacealready handles correctly (see #1243 / #1980 in the map tests).Fix
replacenow:add/deletealready short-circuit (and emit no event / noreportChanged) when a value is respectively already present or already absent, so this is sufficient to makereplaceemit events only for genuine changes. The approach matchesObservableMap.replace.Tests
Added a
#3761describe block inset.jscovering:Set/ observableSetonly emits events for removed & added valuesreplaceThese tests fail on
mainand pass with the fix. The existingset crudtest (which exercisesreplacewith disjoint sets) still passes unchanged. Added a changeset (patch).