Skip to content

Add Csla.Mvvm.ViewModel<T> as the common view model base - #4960

Merged
rockfordlhotka merged 11 commits into
mainfrom
feature/unified-mvvm-viewmodel
Oct 20, 2026
Merged

rockfordlhotka merged 11 commits into
mainfrom
feature/unified-mvvm-viewmodel

Conversation

@rockfordlhotka

@rockfordlhotka rockfordlhotka commented Oct 16, 2026 •

Copy link
Copy Markdown
Member

Closes #1766

Summary

  • New Csla.Mvvm.ViewModel<T> in the core Csla package. It combines the previously separate XAML and Blazor view model logic: model event hooking, lifetime management (n-level undo), status properties with INotifyPropertyChanged, async-aware object and instance permissions, and Refresh/Save/Cancel/AddNew/Remove/Delete.
  • Csla.Blazor.ViewModel<T> inherits from it and keeps only GetPropertyInfo.
  • MAUI and Avalonia Csla.Xaml.ViewModelBase<T> inherit from it directly.
  • WPF ViewModelBase<T> stays a DependencyObject and delegates to a private inner Csla.Mvvm.ViewModel<T>. Virtual hooks route back to the WPF type, so user overrides still apply. The Model dependency property stays in sync when a save or refresh replaces the model.
  • Errors are handled the Blazor way everywhere. Exceptions are captured in Exception/ViewModelErrorText, SaveAsync raises Saved/Error, and BusyTimeout applies.
  • Status-calculation errors are reported the same way on every host. An exception from an authorization rule while the status properties are calculated is captured in Exception/ViewModelErrorText and raises Error. The status properties keep their previous values. It's no longer posted to the SynchronizationContext.
  • With ManageObjectLifetime on, a committed clone is saved. ApplyEdit runs 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.md and releasenotes.md.

  • XAML SaveAsync/RefreshAsync no longer throw. Callers should check Exception or handle Error, which both methods raise.
  • A BusyTimeout expiry is reported as a TimeoutException.
  • SaveAsync does nothing when Model is null. The default DoSaveAsync throws InvalidOperationException for a Model that isn't ISavable.
  • SaveAsync returns Task<T?>.
  • On MAUI and Avalonia, ManageObjectLifetime defaults to true, and the public ManageObjectLifetimeProperty field is removed.
  • The Blazor OnModelChanged() override is replaced by OnModelChanged(T? oldValue, T? newValue).
  • RefreshAsync<F> (XAML) and BeginAddNew (MAUI) are obsolete.
  • The Blazor view model now recalculates status properties and instance permissions on model changes.

Testing

  • New tests: tests/csla.netcore.test/Mvvm/ViewModelTests.cs (28) and tests/Csla.Xaml.Wpf.Tests/ViewModelBaseTests.cs (7), including @swegele's review repro tests.
  • The full csla.test.sln suite passes on net10.0 and net11.0.
  • Csla.Xaml.Avalonia builds.
  • MAUI was not built locally because of a workload/SDK version mismatch; it is left to CI.

🤖 Generated with Claude Code

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.

Comment thread Source/Csla.Xaml.Shared/ViewModelBase.cs Outdated
Comment thread Source/Csla/Mvvm/ViewModel.cs Outdated
Comment thread Source/Csla/Mvvm/ViewModel.cs
rockfordlhotka and others added 2 commits October 16, 2026 00:05
…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>
Comment thread Source/Csla/Mvvm/ViewModel.cs Outdated
Comment on lines +789 to +801
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();
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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?

  1. Clone the Model (still at EditLevel 1).
  2. ApplyEdit on the clone, then save the clone.
  3. On success: ApplyEdit on the Model, merge (or replace), then BeginEdit.
  4. 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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +745 to +758
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 on BusyChanged instead of polling.
  • Do the timeout-to-TimeoutException translation inside the catch that actually sees the exception. For example, a private core method that knows whether the token came from BusyTimeout, with catch (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>();
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +32 to +46
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread Source/Csla/Mvvm/ViewModel.cs Outdated
Comment on lines +619 to +633
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)
{ }
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 an ErrorBoundary can'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?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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*Object getter, which used to throw from the getter. A missing ApplicationContext still throws, because that's a configuration error rather than a rule failure.
  • A failure observed both by a property read and by RefreshAsync is reported once.

Covered by PermissionRuleFailure_IsReportedThroughError. The upgrade doc and release notes describe the change.

Comment thread Source/Csla/Mvvm/ViewModel.cs Outdated
Comment on lines +635 to +645
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;
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread docs/Upgrading to CSLA 11.md Outdated

### 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`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The "no longer throws" migration

Three things about this migration:

  1. Error is save-only. RefreshAsync sets Exception but never raises Error, so someone who follows "handle Error instead" will miss load failures. Could RefreshAsync raise Error too? If not, the doc should say Error covers saves only. Repro (fails today with Expected 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();
    }
  2. The samples now fail silently. Samples/MauiExample/.../PersonEditPage.xaml.cs does await 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.cs and Samples/SimpleNTier/WpfUI/OrderVm.cs (which wraps SaveAsync in a try/catch that can't fire now) follow the same pattern. They also still call the now-obsolete RefreshAsync<T>. They use the NuGet package, so they won't break until the bump, but they're what people copy.
  3. 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);
    (ViewModelErrorText is set without an Exception when the model is invalid, so checking both matters.)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Done in ac95292: RefreshAsync now raises Error too. The Error docs cover refresh failures, save failures and status-calculation failures. Your test passes.
  2. Good catch. The samples pin CSLA 10 packages, which don't have the non-generic RefreshAsync and still throw from SaveAsync, 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.
  3. Added your before/after snippet. Next to it, the doc notes that an invalid model sets only ViewModelErrorText, without Exception or Error.

@swegele

swegele commented Oct 19, 2026

Copy link
Copy Markdown
Contributor

(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 Csla.Mvvm.ViewModel<T>, then built the branch (.NET 11 RC2 SDK). The PR's own tests pass on net10.0 and net11.0 (17 in Mvvm/ViewModelTests, 6 in ViewModelBaseTests), and the three Copilot items look properly fixed to me.

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, swegele/csla → pr-4960-review-tests. It sits on top of this PR's head (ac62125) with one commit per item, already placed in ViewModelTests.cs, MvvmTestObjects.cs and ViewModelBaseTests.cs next to the related tests. Each commit applies on its own (checked), so you can cherry-pick the test together with whichever fix you take and skip the rest. All your existing tests still pass with them added (net10.0 and net11.0). They fail until the fixes land, so I didn't open a PR into your branch.

Inline comments, roughly in priority order:

  1. Cancel after a failed save no longer restores the original values (XAML behavior change), with an ordering that keeps both behaviors.
  2. BusyTimeout never surfaces as a TimeoutException, because the catch can't be reached.
  3. OnPropertyChanged runs during base construction on MAUI/Avalonia/WPF.
  4. Status-calculation errors behave differently per host (a design question), plus a cached permission failure that makes later refreshes fail.
  5. The "no longer throws" migration: Error is save-only, and the samples now navigate away after a failed save.

Smaller items:

  • IsBusy has two writers. With ManageObjectLifetime off, BusinessBase.SaveAsync's MarkIdle clears the VM's IsBusy before the merge and Saved complete. Separate operation/model busy flags would fix it. (SaveAsync_WhileSaving_CanSaveIsFalse stubs DoSaveAsync, so it doesn't exercise this.)
  • Saved is raised even when nothing was saved, when Model is null or not ISavable (ViewModel.cs L803–L805). This was already the case in Blazor.
  • MAUI/Avalonia removed the public ManageObjectLifetimeProperty field. It's worth adding to the breaking-changes list.
  • For collection models, every model change now calls Utilities.GetChildItemType (attribute lookup plus GetProperties) and runs 2–3 permission checks (ViewModel.cs L467–L475). That's new for Blazor. Computing the item type once per Model would avoid the reflection.
  • [DynamicallyAccessedMembers(PublicConstructors)] is on T in the base but not on Csla.Blazor.ViewModel<T> or Csla.Xaml.ViewModelBase<T>. That doesn't warn here, because only Csla enables the trim analyzer, but a trimmed Blazor WebAssembly publish would.
  • Csla.Xaml.Shared/ViewModel.cs still has #if ANDROID || IOS → BeginAddNew() (and the Csla.Axml/Csla.Iosui namespaces), which the new ViewModelBase dropped.
  • New build warning from the test objects: MvvmRoot.Save() (tests/csla.netcore.test/Mvvm/MvvmTestObjects.cs L73) hides the inherited BusinessBase<T>.Save() (CS0108). Renaming the [Insert]/[Update] method (e.g. InsertOrUpdate) would clear it. SavableRoot in ViewModelBaseTests.cs uses the same name.
  • Scope question: is leaving Csla.Web.Mvc.Shared/ViewModelBase.cs out of the consolidation intentional?

The branch covers items 1, 2, 3, 4b and 5. One test I didn't write is a real (unstubbed) save with ManageObjectLifetime off, for the IsBusy item above.

swegele and others added 6 commits October 19, 2026 15:16
…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>
@rockfordlhotka

Copy link
Copy Markdown
Member Author

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:

  • IsBusy has two writers: fixed. The view model's own operations and the Model's BusyChanged now set separate flags, and IsBusy is true while either is set. Replacing Model clears the model flag. SaveAsync_WithoutManagedLifetime_StaysBusyUntilSaved is the real, unstubbed save with ManageObjectLifetime off. It checks that IsBusy is still true when Saved is raised, and fails without the fix.
  • Saved raised when nothing was saved: SaveAsync now does nothing if Model is null. If the Model doesn't implement ISavable, the default DoSaveAsync throws an InvalidOperationException, which is captured in Exception, and Saved isn't raised. An override can still save such a model.
  • MAUI/Avalonia ManageObjectLifetimeProperty: added to the breaking changes in the upgrade doc and release notes.
  • GetChildItemType on every model change: the item type is now cached for the current Model type. I left the per-change permission checks as they are, because the instance-level check depends on the Model's current state.
  • [DynamicallyAccessedMembers]: added to T on Csla.Blazor.ViewModel<T>, on both Csla.Xaml.ViewModelBase<T> variants, and on Csla.Xaml.ViewModel<T>.
  • #if ANDROID || IOS in Csla.Xaml.Shared/ViewModel.cs: removed. That file is compiled only for WPF, so those branches (and the Csla.Axml/Csla.Iosui namespaces) were dead code.
  • CS0108: renamed the [Insert]/[Update] methods to InsertOrUpdate in both test files.
  • Csla.Web.Mvc.Shared/ViewModelBase.cs: leaving it out was intentional. That type is request-scoped. It has only a synchronous Save(ModelStateDictionary, bool) that reports errors through ModelState, and no INotifyPropertyChanged, undo or status properties, so it has little to share with the stateful UI view models.

The full csla.test.sln suite passes on net10.0 and net11.0, with 25 tests in Mvvm/ViewModelTests and 15 in Csla.Xaml.Wpf.Tests.

@swegele swegele left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +763 to +773
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));
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
}

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment on lines +493 to +496
private void SetProperties()
{
ObserveTask(SetPropertiesAsync());
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

rockfordlhotka and others added 2 commits October 19, 2026 18:52
… 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>
@rockfordlhotka
rockfordlhotka merged commit 91e2b3d into main Oct 20, 2026
3 checks passed
@rockfordlhotka
rockfordlhotka deleted the feature/unified-mvvm-viewmodel branch October 20, 2026 00:03
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.

Consolidate UI neutral MVVM types into a Csla.Mvvm project

3 participants