Visitar URL original
Fix ObservableChangeSet.Create's task overloads and build EditDiff on them by dwcullop · Pull Request #1206 · reactivemarbles/DynamicData · GitHub
Skip to content

Fix ObservableChangeSet.Create's task overloads and build EditDiff on them - #1206

Open
dwcullop wants to merge 4 commits into
mainfrom
u/dacullop/main/edit_diff_source_errors
Open

dwcullop wants to merge 4 commits into
mainfrom
u/dacullop/main/edit_diff_source_errors

Conversation

@dwcullop

@dwcullop dwcullop commented Oct 13, 2026 •

Copy link
Copy Markdown
Member

Part of #1142.

EditDiff on an IObservable<IEnumerable<TObject>> used the synchronous ObservableChangeSet.Create, whose callback only gets an ISourceCache and so has no way to fail. A source error after subscription was rethrown by Rx on the thread that delivered it.

The ObservableChangeSet.Create overloads whose task returns nothing can fail and complete through that task, so EditDiff now uses one, with ForEachAsync making the source's lifetime the task:

ObservableChangeSet.Create<TObject, TKey>((cache, cancellationToken) => _source.ForEachAsync(items => cache.EditDiff(items, _equalityComparer), cancellationToken), _keySelector);

Those overloads did not behave like their analog, Observable.Create(Func<IObserver<T>, CancellationToken, Task>). They never completed when the task did, and because they awaited inside an async lambda, a task that ended on a thread with a SynchronizationContext (a UI thread) had its outcome delivered later, on the thread pool.

They are now composed from Rx operators:

Observable.Using(() => new SourceCache<TObject, TKey>(keySelector), cache => Observable.FromAsync(cancellationToken => subscribe(cache, cancellationToken), TaskObservation).Publish(loaded => cache.Connect().TakeUntil(loaded)));
  • Using owns the collection.
  • FromAsync observes the task on ImmediateScheduler, so its outcome arrives on the thread that completes it. Unsubscribing cancels the token.
  • TakeUntil ends the changes when the task succeeds, and fails them when it fails. It holds no lock while delivering.
  • Publish starts the task only once TakeUntil is subscribed to the changes, so a task that finishes during subscription keeps its changes.

The overloads whose task returns an IDisposable or Action are unchanged. They already sit on Rx's Observable.Create(Func<IObserver<T>, CancellationToken, Task<IDisposable>>), which is their analog and disposes clean-up that arrives after unsubscribing. No public signature changes.

Instructions

copilot-instructions.md is the only instruction file that is always in context, and its operator pattern presented Observable.Create as the way operators are built, while rx.instructions.md says composition comes first. It now carries a short version of that rule: compose with existing operators, use ObservableChangeSet.Create rather than routing around it, and the smells that mean a composition was missed. It also says to read rx.instructions.md before writing any Observable.Create or ObservableChangeSet.Create body.

Tests

Cache/ObservableChangeSetFixture:

  • TaskCompletesTheStreamAfterItsChanges
  • TaskFailureIsDeliveredOnTheThreadThatFailsIt
  • TaskThatFinishesDuringSubscriptionStillDeliversItsChanges
  • DisposingTheSubscriptionCancelsTheTask

List/CreationFixtures:

  • TaskCompletesTheStreamAfterItsChanges
  • TaskFailureIsDeliveredOnTheThreadThatFailsIt
  • TaskThatFinishesDuringSubscriptionStillDeliversItsChanges

Six of these fail on main. DisposingTheSubscriptionCancelsTheTask pins behaviour that is kept.

Cache/EditDiffChangeSetFixture:

  • ResultFailsWhenSourceFailsAfterSubscription (new) fails on main.
  • ResultCompletesIfAndOnlyIfSourceCompletes and ResultFailsIfAndOnlyIfSourceFails subscribed to the source rather than to the result of EditDiff, so they never exercised the operator. Both now do.

Notes

Behavioural changes to public APIs. main is on 10.0-preview.

  • ObservableChangeSet.Create with a Func<..., Task> now completes when the task completes, and a failing task's error arrives on the thread that fails it.
  • The list Func<ISourceList<T>, CancellationToken, Task> overload now connects before running the task, like the cache one, so changes made during the task arrive as they happen instead of being folded into one changeset.
  • EditDiff: an error after subscription now arrives as OnError. A source that emits more than once while being subscribed to now produces a changeset per emission, and changes ahead of a failure during subscription are delivered before the error.

…ng them

EditDiff on an IObservable<IEnumerable<TObject>> now forwards source errors to the subscriber. The subscriber is connected to the cache before the source is subscribed, so changes, failure and completion arrive in order. The existing terminal-event tests now subscribe to the result of EditDiff rather than to its source.

Part of #1142.
… them

The Task overloads of ObservableChangeSet.Create, cache and list, now behave like their Observable.Create analogs. A failing task's error arrives on the thread that fails it rather than later on the thread pool, the overloads that return no clean-up complete when their task completes, and the completion-on-dispose that Rx always dropped is gone. Public signatures are unchanged.

EditDiff goes back to ObservableChangeSet.Create, using the Task overload with ForEachAsync so the task's outcome is the source's.

Part of #1142.
@dwcullop dwcullop changed the title Fix that observable EditDiff throws source errors instead of delivering them Fix ObservableChangeSet.Create's task overloads and build EditDiff on them Oct 13, 2026
The overloads whose task returns nothing are now Observable.Using over the collection, with the task observed by FromAsync and ending the collection's changes through TakeUntil. Publish starts the task only once the changes are subscribed, so a task that finishes during subscription keeps its changes. This replaces the hand-wired subscriptions of the previous commit.

The overloads that return an IDisposable or Action are back to main's implementation. They already sit on Rx's Observable.Create(Task<IDisposable>), which disposes late clean-up itself.

Part of #1142.
copilot-instructions.md is the only instruction file that is always in context, and it presented Observable.Create as the operator pattern while rx.instructions.md says composition comes first. It now carries a short version of that rule, the smells that mean a composition was missed, and a pointer to read rx.instructions.md before writing any Observable.Create or ObservableChangeSet.Create body.
ViktorJannicke added a commit to Runic-Artifex/DynamicData that referenced this pull request Oct 20, 2026
Deliver EditDiff source errors; task-backed Create completes with its task (port upstream reactivemarbles#1206)

This branch has not been deployed

No deployments
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