New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[VDG] UI Decoupling #10 #10308
[VDG] UI Decoupling #10 #10308
Conversation
@ichthus1604 @SuperJMN Tests are failing here |
As discussed with @ichthus1604, this PR is only for review purposes, as a interative step for the bigger changes to be easier to reason about. Thus, the code here should NOT be merged to master. All the changes contained in this PR + some fixes to make it compile are included in part 11. cc @soosr |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I could only review it in general, pointed out some nits.
@wieslawsoltes could you also take a look before we continue?
(not mergeable PR)
internal static readonly DiagnosticDescriptor Rule1 = | ||
new("WW001", | ||
"Do not use UiContext or Navigation APIs in ViewModel Constructor", | ||
"UiContext cannot be referenced in a ViewModel's constructor because it hasn't been initialized yet when constructor runs. Use OnNavigatedTo() or OnActivated() instead.", | ||
"Wasabi Wallet", | ||
DiagnosticSeverity.Error, | ||
true); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
private ReceiveAddressViewModel(Wallet wallet, HdPubKey model)
{
CopyAddressCommand = ReactiveCommand.CreateFromTask(async () =>
{
await UIContext.Clipboard.SetTextAsync(Address); // This is perfectly fine.
});
}
So it is not possible to reference UiContext from the private ctor, but the code above is fine. What happens if I immediately call CopyAddressCommand.Execute()
in ctor?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@soosr a runtime exception will be thrown in that case.
the suggested solution is to use OnActivated()
or OnNavigatedTo()
. But if you absolutely must do something in the constructor body that requires the UiContext, then you can change the constructor back to public, add the UiContext parameter explicitly and set the corresponding property as a first step:
public ReceiveAddressViewModel(UiContext UiContext, Wallet wallet, HdPubKey model)
{
UiContext = uiContext; // after this line UiContext is of course already initialized and might be used without problem.
CopyAddressCommand = ReactiveCommand.CreateFromTask(async () =>
{
await UIContext.Clipboard.SetTextAsync(Address); // This is perfectly fine.
});
CopyAddressCommand.Execute(); // this line uses the UiContext safely.
}
This makes the analyzer stop complaining about making the ctor private, and the source generator to not generate any additional constructors for this class, because the requirement to initialize the UiContext has already been met.
I need to document this behavior. What would be the right place for such document?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Reasoning:
ViewModels that depend on external components (such as Navigation, Clipboard, QR Reader, etc) can now access them via their
UIContext
. For instance:var text = await UIContext.Clipboard.GetTextAsync();
await UIContext.QrGenerator.Generate(data);
UIContext.Navigate().To(....)
Problem:
UIContext
is not a singleton, and whileUIContext.Default
static property exists, it only does so temporarily until UI Decoupling refactoring is completed. ViewModels cannot really depend on that or any other static/singleton object because this breaks testability, as unit tests run in parallel and cannot initialize such singleton objects in a per-test fashion, which is required to define mocks and expected behavior in each test.This means that in practice, UIContext is an actual
dependency
of most ViewModels.As any other dependency, UIContext should therefore be a
constructor parameter
of every ViewModel.This basically means that we now need to do
constructor injection
for all ViewModels that require UIContext.As discussed before,
constructor injection
is highly undesirable because it quickly propagates throughout the codebase since dependencies need to be passed from parent ViewModel to child ViewModel in an endless nested fashion.Solution:
This PR introduces a Source Generator that does the following:
1 - Source-generated UIContext constructor
UIContext
in any part of its code, it will create a source-generated constructor containing all the parameters of the existing hand-written constructor, plus the UIContext itself:The above code will produce the following source-generated code:
This newly generated constructor inherits from the hand-written one, therefore the latter's logic is preserved, while initializing UIContext as a final step.
Pit of Success:
Since the UIContext is actually required by the above ViewModel (otherwise
GenerateQrCode()
method will crash), the hand-written constructor which doesn't initialize the UIContext isn't really valid, therefore this PR also introduces a Roslyn Analyzer that will make it a compilation error if this constructor is madepublic
, and instead will request to make itprivate
:Also, since the hand-written constructor actually runs before the UIContext property is initialized, you cannot really use it directly in the constructor's body:
Notice that this only applies to direct use of UIContext in the constructor code, while referencing it for instance in a lambda expression inside the constructor is perfectly fine:
2 - Fluent Navigation:
Approximately half of the occurrences of ViewModel instantiation thoughout the codebase are used for navigation. For instance:
As described above, the
ReceiveAddressesViewModel
constructor now has aUIContext
parameter, therefore theNavigate()
line would need to be changed to:to avoid this, there is now a Fluent Navigation API: for each Routable ViewModel's constructor, a method will be source-generated in the
FluentNavigate
class, with all the same parameters as the constructor, excluding the UIContext:this Fluent API can be accessed by using
UIContext.Navigate().To()
andRoutableViewModel.Navigate().To()
overload with no parameters. The navigation statement now becomes:This not only has the advantage of hiding the UIContext parameter, but also creates a "strongly typed" navigation API where every available ViewModel will be shown in Intellisense when hitting .:
Next Steps:
This PR is not in a workable state. The changes above imply that many ViewModels will require slight code changes, and there are several ones that have compile errors in this PR. The intent is to introduce and discuss these abstractions and make reviewing easier.
I will follow up with the next PR fixing these ViewModel errors, and then several following PRs applying these techniques to ViewModels incrementally until all ViewModels have been covered.
Also, the problem of non navigation-related instantiation of ViewModels remains: for instance, TileViewModels will still need to have the UIContext manually passed to them by MainViewModel or whatever their parent ViewModel is.
There are also further improvements required to these APIs, specifically the addition of
async Task NavigateDialogAsync()
equivalent toFluentNavigate
.Overall, this is the initial introduction of the Pit of Success philosophy where we can enforce certain practices and patterns in the code via compiler tooling, and also provide instant feedback and useful information to all developers at design time.