Skip to content

Commit 0ea94a5

Browse files
authored
feat(reactive): add ContainerDisposable for CompositeDisposable interop (#169)
- Add ContainerDisposable, a MultipleDisposable that converts implicitly to a CompositeDisposable it owns and disposes, so a disposal group flows into APIs written against System.Reactive without a hand conversion. - MultipleDisposable ships in the dependency-free ReactiveUI.Disposables package and cannot name CompositeDisposable, so the seam has to live here. - Add a DisposeWith overload taking the container. Without it a call site that imports this namespace and System.Reactive's fluent disposal helpers has two equally-good candidates and is ambiguous.
1 parent 91eca8f commit 0ea94a5

23 files changed

Lines changed: 640 additions & 0 deletions

File tree

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,12 @@ These two types are the only places the lean surface departs from the System.Rea
8383
variants close the gap: they recompile the same source with `ISequencer` mapped to `IScheduler` and `RxVoid` mapped to
8484
`System.Reactive.Unit`, so code that already speaks System.Reactive sees the types it expects.
8585

86+
Disposal groups are a third seam, and one the shared types cannot close on their own: `MultipleDisposable` ships in the
87+
dependency-free `ReactiveUI.Disposables` package, so it cannot name `CompositeDisposable`. `ReactiveUI.Primitives.Reactive`
88+
adds `ContainerDisposable` for that - a `MultipleDisposable` that converts implicitly to a `CompositeDisposable` it owns
89+
and disposes. Hand one to `DisposeWith`, to a library that takes a `CompositeDisposable`, or to your own helper, and it
90+
just works; anything registered through the composite is disposed with the container.
91+
8692
## Table of contents
8793

8894
1. [Install](#install)
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
2+
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
using System.Reactive.Disposables;
6+
7+
namespace ReactiveUI.Primitives.Reactive.Disposables;
8+
9+
///
10+
/// A that a System.Reactive consumer can use as a
11+
/// , so an activation-scoped container flows into APIs written against
12+
/// System.Reactive - DisposeWith above all - without the caller converting it by hand.
13+
///
14+
///
15+
///
16+
/// The conversion is identity-stable: every conversion of the same container yields the same
17+
/// , and the container owns that composite, so anything registered through it
18+
/// is disposed when the container is. Registering after the container is disposed disposes the registration
19+
/// immediately, matching .
20+
///
21+
///
22+
/// Registrations made through the composite are not visible to the container's own
23+
/// members: the composite occupies a single slot, so Count counts it once
24+
/// and Contains/Remove do not see through it.
25+
///
26+
///
27+
[System.Diagnostics.DebuggerDisplay("Count = {Count}, IsDisposed = {IsDisposed}")]
28+
public sealed class ContainerDisposable : MultipleDisposable
29+
{
30+
/// Serializes creation of the composite.
31+
private readonly Lock _gate = new();
32+
33+
/// The composite handed to System.Reactive consumers, created on first conversion.
34+
private CompositeDisposable? _composite;
35+
36+
/// Initializes a new instance of the class.
37+
public ContainerDisposable()
38+
{
39+
}
40+
41+
/// Initializes a new instance of the class.
42+
/// The first disposable.
43+
/// The second disposable.
44+
public ContainerDisposable(IDisposable first, IDisposable second)
45+
: base(first, second)
46+
{
47+
}
48+
49+
/// Initializes a new instance of the class.
50+
/// The first disposable.
51+
/// The second disposable.
52+
/// The third disposable.
53+
public ContainerDisposable(IDisposable first, IDisposable second, IDisposable third)
54+
: base(first, second, third)
55+
{
56+
}
57+
58+
/// Initializes a new instance of the class from a group of disposables.
59+
/// Disposables that will be disposed together.
60+
/// is .
61+
public ContainerDisposable(params IDisposable[] disposables)
62+
: base(disposables)
63+
{
64+
}
65+
66+
/// Hands the container to a System.Reactive consumer as the composite it owns.
67+
/// The container to convert.
68+
/// is .
69+
public static implicit operator CompositeDisposable(ContainerDisposable container)
70+
{
71+
ArgumentExceptionHelper.ThrowIfNull(container);
72+
73+
return container.ToCompositeDisposable();
74+
}
75+
76+
/// Gets the this container owns, creating it on first call.
77+
/// The composite whose contents are disposed along with this container.
78+
public CompositeDisposable ToCompositeDisposable()
79+
{
80+
lock (_gate)
81+
{
82+
// A disposed composite is still the right answer once the container itself is disposed - it is the
83+
// sink that disposes late arrivals. After Clear() or Remove() the container lives on, so a composite
84+
// it disposed has to be replaced rather than handed out again.
85+
var existing = _composite;
86+
if (existing is not null && (!existing.IsDisposed || IsDisposed))
87+
{
88+
return existing;
89+
}
90+
91+
var created = new CompositeDisposable();
92+
_composite = created;
93+
94+
// Registering the composite with the container is what ties the two lifetimes together. On an
95+
// already-disposed container this disposes the composite instead, which is what a caller adding to
96+
// a disposed container should get.
97+
Add(created);
98+
return created;
99+
}
100+
}
101+
102+
///
103+
protected override void Dispose(bool disposing)
104+
{
105+
base.Dispose(disposing);
106+
107+
// The composite occupies a slot in the container, so the base disposed it just now - or Clear()/Remove()
108+
// did on the way out. Disposing it here is idempotent and states the ownership outright. Nothing in this
109+
// hierarchy has a finalizer and the class is sealed, so this only ever runs on the deterministic path.
110+
_composite?.Dispose();
111+
}
112+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
// Copyright (c) 2019-2026 ReactiveUI Association Incorporated. All rights reserved.
2+
// ReactiveUI Association Incorporated licenses this file to you under the MIT license.
3+
// See the LICENSE file in the project root for full license information.
4+
5+
using ReactiveUI.Primitives.Reactive.Disposables;
6+
7+
namespace ReactiveUI.Primitives.Reactive;
8+
9+
/// Miscellaneous Primitives extensions.
10+
public static partial class LinqExtensions
11+
{
12+
/// Disposal-tracking operators for a disposable.
13+
/// The disposable type.
14+
/// The disposable.
15+
extension<T>(T disposable)
16+
where T : IDisposable
17+
{
18+
/// Disposes the IDisposable with the container.
19+
/// The container.
20+
/// The original disposable.
21+
/// is .
22+
///
23+
/// A converts to a System.Reactive CompositeDisposable, so
24+
/// without this overload a call site that imports both this namespace and System.Reactive's fluent
25+
/// disposal helpers has two equally-good candidates - the inherited
26+
/// DisposeWith(MultipleDisposable) and System.Reactive's DisposeWith(CompositeDisposable)
27+
/// - and is ambiguous. Taking the container exactly makes this an identity match, which wins outright.
28+
///
29+
public T DisposeWith(ContainerDisposable disposables)
30+
{
31+
ArgumentExceptionHelper.ThrowIfNull(disposables);
32+
33+
disposables.Add(disposable);
34+
return disposable;
35+
}
36+
}
37+
}

‎src/ReactiveUI.Primitives.Reactive/PublicAPI/net10.0-android/PublicAPI.txt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ namespace ReactiveUI.Primitives.Reactive
425425
extension(T disposable) where T : System.IDisposable
426426
{
427427
public T DisposeWith(ReactiveUI.Primitives.Disposables.MultipleDisposable disposables) { }
428+
public T DisposeWith(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable disposables) { }
428429
}
429430
extension(System.IObservable source)
430431
{
@@ -1598,6 +1599,20 @@ namespace ReactiveUI.Primitives.Reactive.Core
15981599
public override readonly string ToString() { }
15991600
}
16001601
}
1602+
namespace ReactiveUI.Primitives.Reactive.Disposables
1603+
{
1604+
[System.Diagnostics.DebuggerDisplay("Count = {Count}, IsDisposed = {IsDisposed}")]
1605+
public sealed class ContainerDisposable : ReactiveUI.Primitives.Disposables.MultipleDisposable
1606+
{
1607+
public ContainerDisposable() { }
1608+
public ContainerDisposable(params System.IDisposable[] disposables) { }
1609+
public ContainerDisposable(System.IDisposable first, System.IDisposable second) { }
1610+
public ContainerDisposable(System.IDisposable first, System.IDisposable second, System.IDisposable third) { }
1611+
protected override void Dispose(bool disposing) { }
1612+
public System.Reactive.Disposables.CompositeDisposable ToCompositeDisposable() { }
1613+
public static implicit operator System.Reactive.Disposables.CompositeDisposable(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable container) { }
1614+
}
1615+
}
16011616
namespace ReactiveUI.Primitives.Reactive.Signals
16021617
{
16031618
[System.Diagnostics.DebuggerDisplay("Count = {_count}, MaximumCount = {_maximumCount}, IsDraining = {_isDraining}")]

‎src/ReactiveUI.Primitives.Reactive/PublicAPI/net10.0-ios/PublicAPI.txt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ namespace ReactiveUI.Primitives.Reactive
425425
extension(T disposable) where T : System.IDisposable
426426
{
427427
public T DisposeWith(ReactiveUI.Primitives.Disposables.MultipleDisposable disposables) { }
428+
public T DisposeWith(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable disposables) { }
428429
}
429430
extension(System.IObservable source)
430431
{
@@ -1592,6 +1593,20 @@ namespace ReactiveUI.Primitives.Reactive.Core
15921593
public override readonly string ToString() { }
15931594
}
15941595
}
1596+
namespace ReactiveUI.Primitives.Reactive.Disposables
1597+
{
1598+
[System.Diagnostics.DebuggerDisplay("Count = {Count}, IsDisposed = {IsDisposed}")]
1599+
public sealed class ContainerDisposable : ReactiveUI.Primitives.Disposables.MultipleDisposable
1600+
{
1601+
public ContainerDisposable() { }
1602+
public ContainerDisposable(params System.IDisposable[] disposables) { }
1603+
public ContainerDisposable(System.IDisposable first, System.IDisposable second) { }
1604+
public ContainerDisposable(System.IDisposable first, System.IDisposable second, System.IDisposable third) { }
1605+
protected override void Dispose(bool disposing) { }
1606+
public System.Reactive.Disposables.CompositeDisposable ToCompositeDisposable() { }
1607+
public static implicit operator System.Reactive.Disposables.CompositeDisposable(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable container) { }
1608+
}
1609+
}
15951610
namespace ReactiveUI.Primitives.Reactive.Signals
15961611
{
15971612
[System.Diagnostics.DebuggerDisplay("Count = {_count}, MaximumCount = {_maximumCount}, IsDraining = {_isDraining}")]

‎src/ReactiveUI.Primitives.Reactive/PublicAPI/net10.0-maccatalyst/PublicAPI.txt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ namespace ReactiveUI.Primitives.Reactive
425425
extension(T disposable) where T : System.IDisposable
426426
{
427427
public T DisposeWith(ReactiveUI.Primitives.Disposables.MultipleDisposable disposables) { }
428+
public T DisposeWith(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable disposables) { }
428429
}
429430
extension(System.IObservable source)
430431
{
@@ -1592,6 +1593,20 @@ namespace ReactiveUI.Primitives.Reactive.Core
15921593
public override readonly string ToString() { }
15931594
}
15941595
}
1596+
namespace ReactiveUI.Primitives.Reactive.Disposables
1597+
{
1598+
[System.Diagnostics.DebuggerDisplay("Count = {Count}, IsDisposed = {IsDisposed}")]
1599+
public sealed class ContainerDisposable : ReactiveUI.Primitives.Disposables.MultipleDisposable
1600+
{
1601+
public ContainerDisposable() { }
1602+
public ContainerDisposable(params System.IDisposable[] disposables) { }
1603+
public ContainerDisposable(System.IDisposable first, System.IDisposable second) { }
1604+
public ContainerDisposable(System.IDisposable first, System.IDisposable second, System.IDisposable third) { }
1605+
protected override void Dispose(bool disposing) { }
1606+
public System.Reactive.Disposables.CompositeDisposable ToCompositeDisposable() { }
1607+
public static implicit operator System.Reactive.Disposables.CompositeDisposable(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable container) { }
1608+
}
1609+
}
15951610
namespace ReactiveUI.Primitives.Reactive.Signals
15961611
{
15971612
[System.Diagnostics.DebuggerDisplay("Count = {_count}, MaximumCount = {_maximumCount}, IsDraining = {_isDraining}")]

‎src/ReactiveUI.Primitives.Reactive/PublicAPI/net10.0-macos/PublicAPI.txt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ namespace ReactiveUI.Primitives.Reactive
425425
extension(T disposable) where T : System.IDisposable
426426
{
427427
public T DisposeWith(ReactiveUI.Primitives.Disposables.MultipleDisposable disposables) { }
428+
public T DisposeWith(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable disposables) { }
428429
}
429430
extension(System.IObservable source)
430431
{
@@ -1592,6 +1593,20 @@ namespace ReactiveUI.Primitives.Reactive.Core
15921593
public override readonly string ToString() { }
15931594
}
15941595
}
1596+
namespace ReactiveUI.Primitives.Reactive.Disposables
1597+
{
1598+
[System.Diagnostics.DebuggerDisplay("Count = {Count}, IsDisposed = {IsDisposed}")]
1599+
public sealed class ContainerDisposable : ReactiveUI.Primitives.Disposables.MultipleDisposable
1600+
{
1601+
public ContainerDisposable() { }
1602+
public ContainerDisposable(params System.IDisposable[] disposables) { }
1603+
public ContainerDisposable(System.IDisposable first, System.IDisposable second) { }
1604+
public ContainerDisposable(System.IDisposable first, System.IDisposable second, System.IDisposable third) { }
1605+
protected override void Dispose(bool disposing) { }
1606+
public System.Reactive.Disposables.CompositeDisposable ToCompositeDisposable() { }
1607+
public static implicit operator System.Reactive.Disposables.CompositeDisposable(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable container) { }
1608+
}
1609+
}
15951610
namespace ReactiveUI.Primitives.Reactive.Signals
15961611
{
15971612
[System.Diagnostics.DebuggerDisplay("Count = {_count}, MaximumCount = {_maximumCount}, IsDraining = {_isDraining}")]

‎src/ReactiveUI.Primitives.Reactive/PublicAPI/net10.0-tvos/PublicAPI.txt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ namespace ReactiveUI.Primitives.Reactive
425425
extension(T disposable) where T : System.IDisposable
426426
{
427427
public T DisposeWith(ReactiveUI.Primitives.Disposables.MultipleDisposable disposables) { }
428+
public T DisposeWith(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable disposables) { }
428429
}
429430
extension(System.IObservable source)
430431
{
@@ -1592,6 +1593,20 @@ namespace ReactiveUI.Primitives.Reactive.Core
15921593
public override readonly string ToString() { }
15931594
}
15941595
}
1596+
namespace ReactiveUI.Primitives.Reactive.Disposables
1597+
{
1598+
[System.Diagnostics.DebuggerDisplay("Count = {Count}, IsDisposed = {IsDisposed}")]
1599+
public sealed class ContainerDisposable : ReactiveUI.Primitives.Disposables.MultipleDisposable
1600+
{
1601+
public ContainerDisposable() { }
1602+
public ContainerDisposable(params System.IDisposable[] disposables) { }
1603+
public ContainerDisposable(System.IDisposable first, System.IDisposable second) { }
1604+
public ContainerDisposable(System.IDisposable first, System.IDisposable second, System.IDisposable third) { }
1605+
protected override void Dispose(bool disposing) { }
1606+
public System.Reactive.Disposables.CompositeDisposable ToCompositeDisposable() { }
1607+
public static implicit operator System.Reactive.Disposables.CompositeDisposable(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable container) { }
1608+
}
1609+
}
15951610
namespace ReactiveUI.Primitives.Reactive.Signals
15961611
{
15971612
[System.Diagnostics.DebuggerDisplay("Count = {_count}, MaximumCount = {_maximumCount}, IsDraining = {_isDraining}")]

‎src/ReactiveUI.Primitives.Reactive/PublicAPI/net10.0/PublicAPI.txt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ namespace ReactiveUI.Primitives.Reactive
424424
extension(T disposable) where T : System.IDisposable
425425
{
426426
public T DisposeWith(ReactiveUI.Primitives.Disposables.MultipleDisposable disposables) { }
427+
public T DisposeWith(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable disposables) { }
427428
}
428429
extension(System.IObservable source)
429430
{
@@ -1572,6 +1573,20 @@ namespace ReactiveUI.Primitives.Reactive.Core
15721573
public override readonly string ToString() { }
15731574
}
15741575
}
1576+
namespace ReactiveUI.Primitives.Reactive.Disposables
1577+
{
1578+
[System.Diagnostics.DebuggerDisplay("Count = {Count}, IsDisposed = {IsDisposed}")]
1579+
public sealed class ContainerDisposable : ReactiveUI.Primitives.Disposables.MultipleDisposable
1580+
{
1581+
public ContainerDisposable() { }
1582+
public ContainerDisposable(params System.IDisposable[] disposables) { }
1583+
public ContainerDisposable(System.IDisposable first, System.IDisposable second) { }
1584+
public ContainerDisposable(System.IDisposable first, System.IDisposable second, System.IDisposable third) { }
1585+
protected override void Dispose(bool disposing) { }
1586+
public System.Reactive.Disposables.CompositeDisposable ToCompositeDisposable() { }
1587+
public static implicit operator System.Reactive.Disposables.CompositeDisposable(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable container) { }
1588+
}
1589+
}
15751590
namespace ReactiveUI.Primitives.Reactive.Signals
15761591
{
15771592
[System.Diagnostics.DebuggerDisplay("Count = {_count}, MaximumCount = {_maximumCount}, IsDraining = {_isDraining}")]

‎src/ReactiveUI.Primitives.Reactive/PublicAPI/net11.0-android/PublicAPI.txt‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -425,6 +425,7 @@ namespace ReactiveUI.Primitives.Reactive
425425
extension(T disposable) where T : System.IDisposable
426426
{
427427
public T DisposeWith(ReactiveUI.Primitives.Disposables.MultipleDisposable disposables) { }
428+
public T DisposeWith(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable disposables) { }
428429
}
429430
extension(System.IObservable source)
430431
{
@@ -1598,6 +1599,20 @@ namespace ReactiveUI.Primitives.Reactive.Core
15981599
public override readonly string ToString() { }
15991600
}
16001601
}
1602+
namespace ReactiveUI.Primitives.Reactive.Disposables
1603+
{
1604+
[System.Diagnostics.DebuggerDisplay("Count = {Count}, IsDisposed = {IsDisposed}")]
1605+
public sealed class ContainerDisposable : ReactiveUI.Primitives.Disposables.MultipleDisposable
1606+
{
1607+
public ContainerDisposable() { }
1608+
public ContainerDisposable(params System.IDisposable[] disposables) { }
1609+
public ContainerDisposable(System.IDisposable first, System.IDisposable second) { }
1610+
public ContainerDisposable(System.IDisposable first, System.IDisposable second, System.IDisposable third) { }
1611+
protected override void Dispose(bool disposing) { }
1612+
public System.Reactive.Disposables.CompositeDisposable ToCompositeDisposable() { }
1613+
public static implicit operator System.Reactive.Disposables.CompositeDisposable(ReactiveUI.Primitives.Reactive.Disposables.ContainerDisposable container) { }
1614+
}
1615+
}
16011616
namespace ReactiveUI.Primitives.Reactive.Signals
16021617
{
16031618
[System.Diagnostics.DebuggerDisplay("Count = {_count}, MaximumCount = {_maximumCount}, IsDraining = {_isDraining}")]

0 commit comments

Comments
 (0)