Skip to content

Commit 03ccb2e

Browse files
authored
Fixes 4331 Handle null load state; add tests (#4349)
<!-- Please be sure to read our [Contribute guide](https://www.reactiveui.net/contribute/index.html) before opening a PR. --> ## What kind of change does this PR introduce? <!-- Bug fix, feature, docs update, refactor, ci, ... --> Fix ## What is the current behavior? <!-- You can also link to an open issue here. Use "Closes #123" to auto-close on merge. --> Closes #4331 ## What is the new behavior? <!-- If this is a feature change --> Do not assign a potentially-null loaded state directly to AppState/AppStateValue. Capture the result of ISuspensionDriver.LoadState into a local (object? / TAppState?) and assign item.AppState/item.AppStateValue to the loaded value or fallback to CreateNewAppState/CreateNewAppStateTyped. Add tests in SuspensionHostExtensionsTests and SuspensionHostExtensionsAotTests that verify GetAppState creates and stores a new app state when no persisted state exists. Add ReturnNullOnLoad to TestSuspensionDriver to simulate drivers returning null. Change SuspensionHostTestExecutor to inherit from AppBuilderTestExecutor and delegate ExecuteTest to the base implementation (and add the required using). These changes ensure null-returning drivers are handled safely and covered by tests. ## What might this PR break? ## Checklist - [x] I have read the [Contribute guide](https://www.reactiveui.net/contribute/index.html) - [x] Tests have been added or updated (for bug fixes / features) - [ ] Docs have been added or updated (for bug fixes / features) - [x] Changes target the `main` branch - [x] PR title follows [Conventional Commits](https://www.conventionalcommits.org/) ## Additional information
1 parent 2be6785 commit 03ccb2e

4 files changed

Lines changed: 84 additions & 7 deletions

File tree

src/ReactiveUI/Suspension/SuspensionHostExtensions.cs

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -284,16 +284,20 @@ private static IObservable<Unit> EnsureLoadAppState(this ISuspensionHost item, I
284284
return Observable.Return(Unit.Default);
285285
}
286286

287+
object? loadedState;
288+
287289
try
288290
{
289-
item.AppState = _suspensionDriver.LoadState().Wait();
291+
loadedState = _suspensionDriver.LoadState().Wait();
290292
}
291293
catch (Exception ex)
292294
{
293295
item.Log().Warn(ex, "Failed to restore app state from storage, creating from scratch");
294-
item.AppState = item.CreateNewAppState?.Invoke();
296+
loadedState = null;
295297
}
296298

299+
item.AppState = loadedState ?? item.CreateNewAppState?.Invoke();
300+
297301
return Observable.Return(Unit.Default);
298302
}
299303

@@ -321,16 +325,20 @@ private static IObservable<Unit> EnsureLoadAppState<TAppState>(this ISuspensionH
321325
return Observable.Return(Unit.Default);
322326
}
323327

328+
TAppState? loadedState;
329+
324330
try
325331
{
326-
item.AppStateValue = _suspensionDriver.LoadState(typeInfo).Wait();
332+
loadedState = _suspensionDriver.LoadState(typeInfo).Wait();
327333
}
328334
catch (Exception ex)
329335
{
330336
item.Log().Warn(ex, "Failed to restore app state from storage, creating from scratch");
331-
item.AppStateValue = item.CreateNewAppStateTyped?.Invoke();
337+
loadedState = null;
332338
}
333339

340+
item.AppStateValue = loadedState ?? item.CreateNewAppStateTyped?.Invoke();
341+
334342
return Observable.Return(Unit.Default);
335343
}
336344
}

src/tests/ReactiveUI.Test.Utilities/SuspensionHost/SuspensionHostTestExecutor.cs

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55

66
using System.Reactive;
77

8+
using ReactiveUI.Tests.Utilities.AppBuilder;
9+
810
namespace ReactiveUI.Tests.Utilities.SuspensionHost;
911

1012
/// <summary>
@@ -17,21 +19,21 @@ namespace ReactiveUI.Tests.Utilities.SuspensionHost;
1719
/// - SuspensionHostExtensions.SuspensionDriver
1820
/// Tests using this executor should be marked with [NotInParallel] due to static state modifications.
1921
/// </remarks>
20-
public class SuspensionHostTestExecutor : ITestExecutor
22+
public class SuspensionHostTestExecutor : AppBuilderTestExecutor
2123
{
2224
private Func<IObservable<Unit>>? _previousEnsureLoadAppStateFunc;
2325
private ISuspensionDriver? _previousSuspensionDriver;
2426

2527
/// <inheritdoc/>
26-
public virtual async ValueTask ExecuteTest(TestContext context, Func<ValueTask> testAction)
28+
public override async ValueTask ExecuteTest(TestContext context, Func<ValueTask> testAction)
2729
{
2830
ArgumentNullException.ThrowIfNull(testAction);
2931

3032
SaveStaticState();
3133

3234
try
3335
{
34-
await testAction();
36+
await base.ExecuteTest(context, testAction);
3537
}
3638
finally
3739
{

src/tests/ReactiveUI.Tests/Suspension/SuspensionHostExtensionsAotTests.cs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,36 @@ public async Task GetAppState_Typed_TriggersEnsureLoadAppState()
6969
await Assert.That(driver.LoadStateCallCount).IsEqualTo(1);
7070
}
7171

72+
[Test]
73+
public async Task GetAppState_Typed_WhenNoPersistedState_CreatesAndStoresNewAppState()
74+
{
75+
var createdState = new TestAppState { Value = 99 };
76+
var createNewAppStateCallCount = 0;
77+
using var host = new SuspensionHost<TestAppState>
78+
{
79+
CreateNewAppStateTyped = () =>
80+
{
81+
createNewAppStateCallCount++;
82+
return createdState;
83+
},
84+
IsLaunchingNew = Observable.Never<Unit>(),
85+
IsResuming = Observable.Never<Unit>(),
86+
ShouldPersistState = Observable.Never<IDisposable>(),
87+
ShouldInvalidateState = Observable.Never<Unit>()
88+
};
89+
90+
var driver = new TestSuspensionDriver<TestAppState>();
91+
92+
using var disposable = host.SetupDefaultSuspendResume(TestAppStateContext.Default.TestAppState, driver);
93+
94+
var state = host.GetAppState();
95+
96+
await Assert.That(state).IsSameReferenceAs(createdState);
97+
await Assert.That(host.AppStateValue).IsSameReferenceAs(createdState);
98+
await Assert.That(createNewAppStateCallCount).IsEqualTo(1);
99+
await Assert.That(driver.LoadStateCallCount).IsEqualTo(1);
100+
}
101+
72102
[Test]
73103
public async Task ObserveAppState_Typed_EmitsCurrentValueImmediately()
74104
{

src/tests/ReactiveUI.Tests/SuspensionHostExtensionsTests.cs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,36 @@ public async Task EnsureLoadAppState_LoadStateThrows_CreatesNewAppState()
124124
await Assert.That(driver.LoadStateCallCount).IsEqualTo(1);
125125
}
126126

127+
[Test]
128+
public async Task GetAppState_WhenNoPersistedState_CreatesAndStoresNewAppState()
129+
{
130+
var createdState = new DummyAppState();
131+
var createNewAppStateCallCount = 0;
132+
using var host = new SuspensionHost
133+
{
134+
CreateNewAppState = () =>
135+
{
136+
createNewAppStateCallCount++;
137+
return createdState;
138+
},
139+
IsLaunchingNew = Observable.Never<Unit>(),
140+
IsResuming = Observable.Never<Unit>(),
141+
ShouldPersistState = Observable.Never<IDisposable>(),
142+
ShouldInvalidateState = Observable.Never<Unit>()
143+
};
144+
145+
var driver = new TestSuspensionDriver { ReturnNullOnLoad = true };
146+
147+
using var disposable = host.SetupDefaultSuspendResume(driver);
148+
149+
var state = host.GetAppState<DummyAppState>();
150+
151+
await Assert.That(state).IsSameReferenceAs(createdState);
152+
await Assert.That(host.AppState).IsSameReferenceAs(createdState);
153+
await Assert.That(createNewAppStateCallCount).IsEqualTo(1);
154+
await Assert.That(driver.LoadStateCallCount).IsEqualTo(1);
155+
}
156+
127157
[Test]
128158
public async Task EnsureLoadAppState_WithExistingAppState_DoesNotLoad()
129159
{
@@ -475,6 +505,8 @@ private class TestSuspensionDriver : ISuspensionDriver
475505

476506
public bool ShouldThrowOnLoad { get; set; }
477507

508+
public bool ReturnNullOnLoad { get; set; }
509+
478510
public object? StateToLoad { get; set; }
479511

480512
public IObservable<Unit> InvalidateState()
@@ -497,6 +529,11 @@ public IObservable<Unit> InvalidateState()
497529
ImmediateScheduler.Instance);
498530
}
499531

532+
if (ReturnNullOnLoad)
533+
{
534+
return Observable.Return<object?>(null, ImmediateScheduler.Instance);
535+
}
536+
500537
return Observable.Return(StateToLoad ?? new DummyAppState(), ImmediateScheduler.Instance);
501538
}
502539

0 commit comments

Comments
 (0)