Repository navigation
Add Csla.Mvvm.ViewModel<T> as the common view model base - #4960
Conversation
Blazor, MAUI and Avalonia view models inherit from it; the WPF ViewModelBase keeps DependencyObject and delegates to it. All view models now capture save/refresh errors, apply edits before cloning, and expose the same status properties. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
There was a problem hiding this comment.
🟡 Changes recommended
Event subscription handling, WPF setter forwarding, and busy-state gating need correction.
3 open findings
What changed in this PR
Consolidates shared view-model behavior into the core Csla.Mvvm.ViewModel<T>, reducing duplication across UI integrations.
Changes:
- Centralizes model lifetime, operations, status, and authorization handling.
- Adapts Blazor and XAML view models to the shared implementation.
- Adds regression tests and migration guidance.
| File | Description |
|---|---|
| Source/tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs | Tests WPF delegation and model synchronization. |
| Source/tests/csla.netcore.test/Mvvm/ViewModelTests.cs | Tests shared view-model behavior. |
| Source/tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs | Adds test models and authorization rules. |
| Source/Csla/Mvvm/ViewModel.cs | Implements the common view-model base. |
| Source/Csla.Xaml.Shared/ViewModelBase.cs | Adds inheritance or WPF delegation. |
| Source/Csla.Blazor/ViewModel.cs | Retains Blazor-specific property helpers. |
| releasenotes.md | Summarizes changes and compatibility impacts. |
| docs/Upgrading to CSLA 11.md | Documents migration steps. |
🧠 Review effort: Balanced
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
…ooks - WPF inner view model forwards both accessors of every status and permission property to the owner, so overridden protected setters run - Status calculation treats the view model's own IsBusy as busy, so CanSave/CanCancel are false while a cloned model is being saved - SaveAsync unhooks before re-hooking model events in its finally block, avoiding duplicate handlers after an invalid-model return or a save that replaces Model Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
| UnhookChangedEvents(Model); | ||
|
|
||
| var savable = Model as ISavable; | ||
| if (ManageObjectLifetime) | ||
| { | ||
| // apply changes - must apply edit to Model not clone | ||
| if (Model is ISupportUndo undoable) | ||
| undoable.ApplyEdit(); | ||
|
|
||
| // clone the object if possible | ||
| if (savable is ICloneable clonable) | ||
| savable = (ISavable)clonable.Clone(); | ||
| } |
There was a problem hiding this comment.
Cancel after a failed save no longer restores the original values
With ManageObjectLifetime on, ApplyEdit now runs on the Model before the clone is saved. If the save fails, the finally block calls BeginEdit on the edited state. That means Cancel can no longer get back to the original values.
Scenario (WPF, where ManageObjectLifetime defaults to true): the user renames "Acme" to "Acme Corp" and saves, and the server rejects the save. In CSLA 10 the Model still had its original snapshot, so Cancel restored "Acme". With this change, Cancel keeps "Acme Corp".
I can see why the Blazor order applies the edit first. GraphMerger doesn't touch the undo stack, so merging into a Model that is still at EditLevel 1 would leave a stale pre-save snapshot behind after a successful save. Could we get both behaviors with this order?
- Clone the Model (still at EditLevel 1).
ApplyEditon the clone, then save the clone.- On success:
ApplyEditon the Model, merge (or replace), thenBeginEdit. - On failure: leave the Model untouched, so its original snapshot is still there for Cancel.
Repro (fails today with Expected vm.Model.Name to be "Original" ... but "fail"):
[TestMethod]
public async Task ManageObjectLifetime_SaveFails_CancelRestoresOriginal()
{
var root = await CreateRootAsync();
root.Name = "Original";
var vm = CreateViewModel<MvvmRoot>();
vm.ManageObjectLifetime = true;
vm.Model = root; // BeginEdit: snapshot has "Original"
root.Name = MvvmRoot.FailOnSave; // the edit the server rejects
await vm.SaveAsync();
vm.Exception.Should().NotBeNull();
vm.Cancel();
vm.Model.Name.Should().Be("Original");
}The upgrade doc mentions the new ApplyEdit order but not this consequence. If the current behavior stays, it's worth a line there.
There was a problem hiding this comment.
Fixed in ac95292, with the order you suggested. SaveAsync clones the Model while it's still at EditLevel 1, calls ApplyEdit on the clone, and saves the clone. It calls ApplyEdit on the Model only after the save succeeds. The default DoSaveAsync does this just before the merge (or replace), so the merge sees the same Model state as before. If a DoSaveAsync override keeps the Model without committing it, SaveAsync commits it afterwards.
If the save fails, the Model keeps its snapshot, and Cancel restores "Original". Your test passes. ManageObjectLifetime_SaveSucceeds_CancelKeepsSavedValues covers the other direction: after a successful save, Cancel doesn't go back to a stale pre-save snapshot. The upgrade doc now describes the new order.
| public virtual async Task<T?> SaveAsync() | ||
| { | ||
| try | ||
| { | ||
| using var cts = BusyTimeout.ToCancellationTokenSource(); | ||
| await SaveAsync(cts.Token); | ||
| } | ||
| catch (TaskCanceledException tcex) | ||
| { | ||
| var timeout = new TimeoutException(nameof(SaveAsync), tcex); | ||
| SetError(timeout, timeout.Message); | ||
| } | ||
| return Model; | ||
| } |
There was a problem hiding this comment.
BusyTimeout never surfaces as a TimeoutException
The catch (TaskCanceledException) here can't be reached. SaveAsync(ct) catches every exception itself (L811), so when BusyTimeout expires the cancellation is recorded as-is, and the outer catch never runs. On top of that, ct.ThrowIfCancellationRequested() throws OperationCanceledException, which this catch wouldn't match anyway. The result is that ViewModelErrorText shows "A task was canceled." (or "The operation was canceled."), and Error carries a cancellation exception instead of a TimeoutException.
This was already there in the Blazor view model. It's more visible now that the upgrade doc advertises BusyTimeout for XAML.
A possible shape:
- Replace the 1 ms polling loop (L774–L778) with
BusyHelper.WaitForIdle(obj, ct), which waits onBusyChangedinstead of polling. - Do the timeout-to-
TimeoutExceptiontranslation inside the catch that actually sees the exception. For example, a private core method that knows whether the token came fromBusyTimeout, withcatch (OperationCanceledException ex) when (ct.IsCancellationRequested).
Repro (fails today with Expected type to be System.TimeoutException, but found System.Threading.Tasks.TaskCanceledException, and ViewModelErrorText is "A task was canceled."):
[Serializable]
public class BusyRoot : BusinessBase<BusyRoot>
{
public void StayBusy() => MarkBusy();
[Create] private void Create() { }
}
[TestMethod]
public async Task SaveAsync_BusyPastTimeout_ReportsTimeoutException()
{
var root = await _testHost.GetDataPortal<BusyRoot>().CreateAsync();
root.StayBusy();
var vm = CreateViewModel<BusyRoot>();
vm.Model = root;
vm.BusyTimeout = TimeSpan.FromMilliseconds(200);
await vm.SaveAsync();
vm.Exception.Should().BeOfType<TimeoutException>();
}There was a problem hiding this comment.
Fixed in ac95292. The 1 ms polling loop is replaced by BusyHelper.WaitForIdle(obj, ct). Polling remains only for an ITrackStatus model that isn't INotifyBusy.
The translation now happens in the catch that sees the exception. SaveAsync() records its timeout token in a field, so the call still goes through the virtual SaveAsync(CancellationToken) and any override. The wait catches OperationCanceledException only when that token was canceled, and throws a TimeoutException. Its message has the same format as BusyHelper's (<Type>.SaveAsync - <timeout>.). A cancellation from the caller's own token is still reported as a cancellation. The unreachable outer catch is gone.
Your test passes. I also updated the existing Blazor test SavingFailure_ErrorTimeOut, which asserted the old "The operation was canceled." message.
| protected ViewModelBase() | ||
| { | ||
| ManageObjectLifetime = true; | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// Creates an instance of the type. | ||
| /// </summary> | ||
| /// <param name="applicationContext">The current ApplicationContext.</param> | ||
| /// <exception cref="ArgumentNullException"><paramref name="applicationContext"/> is <see langword="null"/>.</exception> | ||
| protected ViewModelBase(ApplicationContext applicationContext) | ||
| : base(applicationContext) | ||
| { | ||
| ManageObjectLifetime = true; | ||
| } |
There was a problem hiding this comment.
OnPropertyChanged runs during base construction (also WPF Initialize, L112–L116)
The base _manageObjectLifetime starts at false, so setting it to true here raises PropertyChanged. That goes through the virtual OnPropertyChanged. On WPF it's _inner.ManageObjectLifetime = ..., which InnerViewModel forwards to _owner.OnPropertyChanged. Either way, a subclass override runs before the subclass constructor body. Before this change, WPF used a DP default and MAUI/Avalonia used a plain field, so nothing was raised during construction.
The pattern that breaks is a VM that creates its commands in the constructor and refreshes them from OnPropertyChanged. Confirmed on WPF: constructing this VM throws NullReferenceException (I could only run WPF here; MAUI/Avalonia set the property the same way in their constructors).
private class CommandRefreshingViewModel : ViewModel<SavableRoot>
{
private readonly List<string> _refreshed;
public CommandRefreshingViewModel() { _refreshed = []; }
protected override void OnPropertyChanged(string propertyName)
{
base.OnPropertyChanged(propertyName);
_refreshed.Add(propertyName); // null during base construction
}
}
[TestMethod]
public void Construction_DoesNotCallOnPropertyChangedEarly()
{
DispatcherContext.Run(async () =>
{
await Task.Yield();
_ = new CommandRefreshingViewModel(); // throws NullReferenceException today
});
}Could the default be set without notifying? For example, a protected ViewModel(ApplicationContext?, bool manageObjectLifetime) overload that assigns the field directly. That would also keep the WPF Initialize path quiet.
There was a problem hiding this comment.
Fixed in ac95292, the way you suggested. Csla.Mvvm.ViewModel<T> has two new protected constructors, (ApplicationContext, bool manageObjectLifetime) and (bool manageObjectLifetime). They assign the field without raising PropertyChanged. The MAUI/Avalonia ViewModelBase<T> passes true through them. On WPF, the inner view model gets the dependency property's value through its constructor, and Initialize no longer sets it.
Your WPF test passes, and Avalonia builds. I can't build MAUI locally, so that's left to CI.
| private static async void ObserveTask(Task task) | ||
| { | ||
| // async void so exceptions surface on the SynchronizationContext | ||
| // (such as a UI thread), just as a synchronous exception would. | ||
| // Without a context an async void exception would crash the | ||
| // process, so it is not rethrown; the status properties are | ||
| // simply left unchanged. | ||
| var context = SynchronizationContext.Current; | ||
| try | ||
| { | ||
| await task; | ||
| } | ||
| catch (Exception) when (context is null) | ||
| { } | ||
| } |
There was a problem hiding this comment.
Design question: status-calculation errors behave differently per host
What happens to an exception from an authorization rule during the status calculation depends on the host:
- With a
SynchronizationContext(WPF, MAUI, Blazor Server), it's posted as unhandled. On the desktop that crashes the app unless there's a global handler. In Blazor Server it typically ends the circuit, and anErrorBoundarycan't catch a posted exception. - Without one (tests, console, typically Blazor WebAssembly), it's swallowed and the
Can*properties keep their old values.
XAML already behaved the first way. Blazor is newly exposed, because its VM didn't run this calculation before. Since the PR's theme is "errors are handled the Blazor way everywhere", would it make sense to catch these in SetPropertiesAsync and surface them through Exception/ViewModelErrorText (and maybe Error), so every host behaves the same?
There was a problem hiding this comment.
Agreed, consistency is the better choice. In ac95292, an exception from the status calculation is reported through Exception, ViewModelErrorText and Error on every host, and the Can* properties keep their previous values. Nothing is posted to the SynchronizationContext any more. A throwing rule no longer crashes a desktop app or ends a Blazor Server circuit, and an Error handler can show it.
Two details:
- This includes synchronous rules evaluated through a
Can*Objectgetter, which used to throw from the getter. A missingApplicationContextstill throws, because that's a configuration error rather than a rule failure. - A failure observed both by a property read and by
RefreshAsyncis reported once.
Covered by PermissionRuleFailure_IsReportedThroughError. The upgrade doc and release notes describe the change.
| private Task EnsureObjectLevelPermissionsAsync() | ||
| { | ||
| if (!_objectLevelPermissionsRequested) | ||
| { | ||
| // set the flag first: the property setters raise PropertyChanged, | ||
| // and handlers may read the permission properties re-entrantly | ||
| _objectLevelPermissionsRequested = true; | ||
| _objectLevelPermissionsTask = LoadObjectLevelPermissionsAsync(); | ||
| } | ||
| return _objectLevelPermissionsTask ?? Task.CompletedTask; | ||
| } |
There was a problem hiding this comment.
A failed permission load stays cached
If LoadObjectLevelPermissionsAsync faults, _objectLevelPermissionsRequested stays true and the faulted task stays cached. RefreshAsync awaits that task (L718), so one transient failure in an async auth rule makes every later RefreshAsync on that VM fail with the same exception. This was already in Blazor and now applies to XAML too (the old XAML RefreshAsync didn't await permissions). Clearing the flag and task when the load faults would let the next call retry.
Repro: an ObjectAuthorizationRules CreateObject rule that throws on its first call only. The first RefreshAsync fails as expected. The second fails too, with the same "transient failure" message, although the rule would now succeed. As a control, a new view model refreshes fine after the same failure, so the stale state is this cached task and not CSLA's permission cache.
public class FlakyAuthRule(AuthorizationActions action) : AuthorizationRule(action)
{
public static int Calls;
protected override void Execute(IAuthorizationContext context)
{
if (Interlocked.Increment(ref Calls) == 1)
throw new InvalidOperationException("transient failure in an authorization rule");
context.HasPermission = true;
}
}
[Serializable]
public class FlakyAuthRoot : BusinessBase<FlakyAuthRoot>
{
public static void AddObjectAuthorizationRules() =>
BusinessRules.AddRule(typeof(FlakyAuthRoot), new FlakyAuthRule(AuthorizationActions.CreateObject));
[Fetch] private void Fetch(int id) { }
}
[TestMethod]
public async Task TransientPermissionFailure_LaterRefreshSucceeds()
{
FlakyAuthRule.Calls = 0;
var portal = _testHost.GetDataPortal<FlakyAuthRoot>();
var vm = CreateViewModel<FlakyAuthRoot>();
await vm.RefreshAsync(() => portal.FetchAsync(1)); // fails: transient
await vm.RefreshAsync(() => portal.FetchAsync(1)); // should succeed
vm.Exception.Should().BeNull(); // today: the same InvalidOperationException
}There was a problem hiding this comment.
Fixed in ac95292. If the previous permission load faulted, RefreshAsync starts a new one.
I limited the retry to RefreshAsync. If property reads also retried, a rule that always fails would be evaluated, and reported, on every read. A PropertyChanged handler that reads a Can*Object property could also loop. RefreshAsync now starts the load before it sets IsBusy, so the status calculation that IsBusy triggers doesn't hit the failure first. Your test passes.
|
|
||
| ### XAML (WPF, MAUI and Avalonia) | ||
|
|
||
| * `SaveAsync` and `RefreshAsync` no longer throw. Exceptions are captured in the `Exception` and `ViewModelErrorText` properties, and `SaveAsync` also raises the new `Error` event. Code that wrapped these calls in `try`/`catch` should check `Exception` or handle `Error` instead. If `RefreshAsync` fails, `Model` is set to `null`. |
There was a problem hiding this comment.
The "no longer throws" migration
Three things about this migration:
Erroris save-only.RefreshAsyncsetsExceptionbut never raisesError, so someone who follows "handleErrorinstead" will miss load failures. CouldRefreshAsyncraiseErrortoo? If not, the doc should sayErrorcovers saves only. Repro (fails today withExpected raised to be True, but found False):[TestMethod] public async Task RefreshFailure_RaisesError() { var vm = CreateViewModel<MvvmRoot>(); var raised = false; vm.Error += (_, _) => raised = true; await vm.RefreshAsync(() => _testHost.GetDataPortal<MvvmRoot>().FetchAsync(-1)); vm.Exception.Should().NotBeNull(); raised.Should().BeTrue(); }
- The samples now fail silently.
Samples/MauiExample/.../PersonEditPage.xaml.csdoesawait vm.SaveAsync(); await Shell.Current.GoToAsync("..", true);. Before, a failed save threw and the page stayed put. Now a failed (or invalid) save returns normally, and the page navigates away, discarding the user's edits with no message.Samples/AvaloniaExample/.../PersonEditPage.axaml.csandSamples/SimpleNTier/WpfUI/OrderVm.cs(which wrapsSaveAsyncin a try/catch that can't fire now) follow the same pattern. They also still call the now-obsoleteRefreshAsync<T>. They use the NuGet package, so they won't break until the bump, but they're what people copy. - A before/after snippet in this section would make the change hard to miss:
(
// CSLA 10 try { await vm.SaveAsync(); await NavigateBack(); } catch (Exception ex) { ShowError(ex); } // CSLA 11 await vm.SaveAsync(); if (vm.Exception is null && string.IsNullOrEmpty(vm.ViewModelErrorText)) await NavigateBack(); else ShowError(vm.ViewModelErrorText);
ViewModelErrorTextis set without anExceptionwhen the model is invalid, so checking both matters.)
There was a problem hiding this comment.
- Done in ac95292:
RefreshAsyncnow raisesErrortoo. TheErrordocs cover refresh failures, save failures and status-calculation failures. Your test passes. - Good catch. The samples pin CSLA 10 packages, which don't have the non-generic
RefreshAsyncand still throw fromSaveAsync, so changing them in this PR would break their build. Use non-generic RefreshAsync in samples when they move to CSLA 11 #4961 already tracks moving them to CSLA 11. I added the three save-then-navigate pages there. - Added your before/after snippet. Next to it, the doc notes that an invalid model sets only
ViewModelErrorText, withoutExceptionorError.
|
(Summary for my review above. It got dropped when I submitted.) Thanks, Rocky. Pulling the Blazor and XAML view models onto one base is a real improvement, and the upgrade doc does a good job of spelling out what moves. I reviewed it by reading the old Blazor and XAML implementations side by side with the new For each concrete item below, I wrote a small test against this branch that asserts the behavior I'm suggesting. Each one fails today with the message quoted in the comment. The same tests are also on a branch of my fork, Inline comments, roughly in priority order:
Smaller items:
The branch covers items 1, 2, 3, 4b and 5. One test I didn't write is a real (unstubbed) save with |
…lues With ManageObjectLifetime on, SaveAsync applies the edit to the Model before saving the clone, so after a failed save Cancel can no longer get back to the values the Model had when editing began. Fails on the PR as written (Name is "fail", expected "Original"); see the review comment on SaveAsync for an ordering that keeps both behaviors. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
SaveAsync(ct) catches every exception itself, so the outer catch (TaskCanceledException) in SaveAsync() that converts an expired BusyTimeout into a TimeoutException is never reached. Fails on the PR as written: Exception is TaskCanceledException and ViewModelErrorText is "A task was canceled.". BusyRoot is a model that stays busy on demand. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…anged early The base constructors now set ManageObjectLifetime (MAUI/Avalonia directly, WPF via the inner view model), which raises PropertyChanged through the virtual OnPropertyChanged before the subclass constructor body runs. A subclass that uses constructor-initialized state there throws. Fails on the PR as written with NullReferenceException (WPF; MAUI/Avalonia take the same path but weren't run). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
If loading the object-level permissions faults, the faulted task stays cached and RefreshAsync awaits it, so every later refresh on that view model fails with the same exception even after the rule would succeed. Fails on the PR as written. (A control run with a fresh view model after the same failure succeeds, so the stale state is the cached task, not the CSLA permission cache.) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The upgrade doc tells XAML users who wrapped SaveAsync/RefreshAsync in try/catch to check Exception or handle Error instead, but RefreshAsync never raises Error. Fails on the PR as written. If Error is meant to be save-only, drop this test and make the doc say so instead. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Save a committed clone and apply the Model's edit only after the save succeeds, so Cancel after a failed save restores the original values - Wait with BusyHelper.WaitForIdle and report BusyTimeout as a TimeoutException - Add protected constructors that set ManageObjectLifetime without raising PropertyChanged during construction (MAUI, Avalonia, WPF) - Report authorization rule failures in the status calculation through Exception, ViewModelErrorText and Error on every host - Retry a failed object-level permission load on the next RefreshAsync - Raise Error when RefreshAsync fails - Track view model and Model busy state separately - Don't raise Saved when there is no Model to save - Cache the child item type, add DynamicallyAccessedMembers to the derived view models, and remove dead ANDROID/IOS code - Document the changes and add a before/after migration snippet Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
Thanks, Sean. This was a thorough review, and the one-commit-per-item repro branch made it easy to work through. I cherry-picked all five repro commits onto the branch, keeping your authorship. They pass with the fixes in ac95292. I've replied to each inline comment. Smaller items:
The full |
swegele
left a comment
There was a problem hiding this comment.
Approving. Thanks for the quick turnaround, Rocky. I pulled ac95292 and ran the view model suites on net10.0 and net11.0: 25 in Mvvm/ViewModelTests, 15 in Csla.Xaml.Wpf.Tests and 32 in Csla.Blazor.Test, all passing, including the five repros. The fixes read well, and your reasoning on the MVC view model and on moving the samples in #4961 makes sense.
I left two small items inline on the new error reporting, each checked with a quick test against ac95292. Neither blocks the merge, so fix them here or in a follow-up, whichever suits you.
| private void ReportError(Exception exception) | ||
| { | ||
| // a failed permission load can be observed by both a property | ||
| // read and RefreshAsync; report it once | ||
| if (ReferenceEquals(Exception, exception)) | ||
| return; | ||
|
|
||
| var errorText = exception is DataPortalException dpe ? dpe.BusinessExceptionMessage : exception.Message; | ||
| SetError(exception, errorText); | ||
| Error?.Invoke(this, new Core.ErrorEventArgs(this, exception)); | ||
| } |
There was a problem hiding this comment.
Non-blocking: a status-calculation rule that keeps failing raises Error on every model change
The retry limit covers the object-level load. The instance-level checks in SetPropertiesAsync still run on every property change, though, and each failure is a new exception, so this ReferenceEquals check doesn't catch the repeats. With a per-type DeleteObject rule that throws only for instance checks, setting Model and then typing three characters raised Error four times. An app that shows a dialog from Error would show one per keystroke.
Could a status-calculation failure be reported only when it starts? For example, remember that the last calculation failed, and clear that when one succeeds.
// fails only for instance checks, so the object-level permission load succeeds
public class InstanceOnlyFailRule(AuthorizationActions action) : AuthorizationRule(action)
{
protected override void Execute(IAuthorizationContext context)
{
if (context.Target != null)
throw new InvalidOperationException("instance rule keeps failing");
context.HasPermission = true;
}
}
[Serializable]
public class InstanceFailRoot : BusinessBase<InstanceFailRoot>
{
public static readonly PropertyInfo<string> NameProperty = RegisterProperty<string>(nameof(Name));
public string Name
{
get => GetProperty(NameProperty);
set => SetProperty(NameProperty, value);
}
public static void AddObjectAuthorizationRules() =>
BusinessRules.AddRule(typeof(InstanceFailRoot), new InstanceOnlyFailRule(AuthorizationActions.DeleteObject));
[Create] private void Create() { }
}
[TestMethod]
public async Task PersistentStatusRuleFailure_IsReportedOnce()
{
var root = await _testHost.GetDataPortal<InstanceFailRoot>().CreateAsync();
var vm = CreateViewModel<InstanceFailRoot>();
var errors = 0;
vm.Error += (_, _) => errors++;
vm.Model = root;
root.Name = "a";
root.Name = "ab";
root.Name = "abc";
errors.Should().Be(1); // today: 4, one per model change
}There was a problem hiding this comment.
Fixed in b0ad06c, the way you suggested. The view model remembers that the last status calculation failed. While it keeps failing, the calculation still runs, but the failure isn't reported again. The flag clears when a calculation completes successfully. A calculation that a newer one replaces doesn't count as a success, so it can't clear the flag early.
Your test is in as PersistentStatusRuleFailure_IsReportedOnce (one Error instead of four). StatusRuleFailure_IsReportedAgainAfterRecovery checks the other half: fail, recover, fail again raises Error twice. The upgrade doc mentions the once-per-failure behavior.
| private void SetProperties() | ||
| { | ||
| ObserveTask(SetPropertiesAsync()); | ||
| } |
There was a problem hiding this comment.
Non-blocking, small: a missing ApplicationContext throws from the Can*Object getters, but not from the Model setter
On a NoContextViewModel, vm.Model = root doesn't throw. The context is first read inside SetPropertiesAsync, so ObserveTask reports the InvalidOperationException ("…was created without an ApplicationContext…") through Error. Reading ApplicationContext here, before starting SetPropertiesAsync, makes the setter throw too. That's the same idea as in EnsureObjectLevelPermissionsAsync. I tried a one-line version (_ = ApplicationContext; before the ObserveTask call). The setter then throws, and the existing Mvvm and WPF tests still pass.
There was a problem hiding this comment.
Fixed in b0ad06c with your one-liner. SetProperties reads ApplicationContext before it starts the calculation, so vm.Model = root on a view model without a context throws, just like the Can*Object getters. Covered by ParameterlessConstructor_WithoutContext_SettingModelThrows.
… setter - Report a status-calculation failure when it starts, and again only after a calculation has succeeded, instead of on every model change - Resolve ApplicationContext before starting the status calculation, so a missing context throws from the Model setter Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Closes #1766
Summary
Csla.Mvvm.ViewModel<T>in the coreCslapackage. It combines the previously separate XAML and Blazor view model logic: model event hooking, lifetime management (n-level undo), status properties withINotifyPropertyChanged, async-aware object and instance permissions, and Refresh/Save/Cancel/AddNew/Remove/Delete.Csla.Blazor.ViewModel<T>inherits from it and keeps onlyGetPropertyInfo.Csla.Xaml.ViewModelBase<T>inherit from it directly.ViewModelBase<T>stays aDependencyObjectand delegates to a private innerCsla.Mvvm.ViewModel<T>. Virtual hooks route back to the WPF type, so user overrides still apply. TheModeldependency property stays in sync when a save or refresh replaces the model.Exception/ViewModelErrorText,SaveAsyncraisesSaved/Error, andBusyTimeoutapplies.Exception/ViewModelErrorTextand raisesError. The status properties keep their previous values. It's no longer posted to theSynchronizationContext.ManageObjectLifetimeon, a committed clone is saved.ApplyEditruns on the Model only after the save succeeds, so Cancel after a failed save still restores the original values.Breaking changes
These are documented in
docs/Upgrading to CSLA 11.mdandreleasenotes.md.SaveAsync/RefreshAsyncno longer throw. Callers should checkExceptionor handleError, which both methods raise.BusyTimeoutexpiry is reported as aTimeoutException.SaveAsyncdoes nothing whenModelisnull. The defaultDoSaveAsyncthrowsInvalidOperationExceptionfor a Model that isn'tISavable.SaveAsyncreturnsTask<T?>.ManageObjectLifetimedefaults totrue, and the publicManageObjectLifetimePropertyfield is removed.OnModelChanged()override is replaced byOnModelChanged(T? oldValue, T? newValue).RefreshAsync<F>(XAML) andBeginAddNew(MAUI) are obsolete.Testing
tests/csla.netcore.test/Mvvm/ViewModelTests.cs(28) andtests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs(7), including @swegele's review repro tests.csla.test.slnsuite passes on net10.0 and net11.0.Csla.Xaml.Avaloniabuilds.🤖 Generated with Claude Code