From 303a9bfe3312f1db51aa2ad5e354e3f484b41f00 Mon Sep 17 00:00:00 2001
From: Safia Abdalla <safia@microsoft.com>
Date: Wed, 15 Jul 2020 04:20:08 +0000
Subject: [PATCH] Spruce up async handling in OnNavigateAsync callback in
 Blazor router (#23835)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

* Spruce up async handling in OnNavigateAsync

* Apply suggestions from code review

Co-authored-by: Günther Foidl <gue@korporal.at>

* Ensure previous task awaited before starting next one

* Apply suggestions from code review

Co-authored-by: Pranav K <prkrishn@hotmail.com>

* Validate no exceptions throw on multiple invocations

* Address feedback from peer review

Co-authored-by: Günther Foidl <gue@korporal.at>
Co-authored-by: Pranav K <prkrishn@hotmail.com>
---
 .../Components/src/Properties/AssemblyInfo.cs |   2 +
 .../Components/src/Routing/Router.cs          |  52 ++++++---
 .../Components/test/Routing/RouterTest.cs     | 104 ++++++++++++++++++
 3 files changed, 145 insertions(+), 13 deletions(-)
 create mode 100644 src/Components/Components/test/Routing/RouterTest.cs

diff --git a/src/Components/Components/src/Properties/AssemblyInfo.cs b/src/Components/Components/src/Properties/AssemblyInfo.cs
index 45c4bb49abd..31d53210c5c 100644
--- a/src/Components/Components/src/Properties/AssemblyInfo.cs
+++ b/src/Components/Components/src/Properties/AssemblyInfo.cs
@@ -8,3 +8,5 @@ using System.Runtime.CompilerServices;
 [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
 [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Web.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
 [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Components.Web.Extensions.Tests, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")]
+
+[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")]
diff --git a/src/Components/Components/src/Routing/Router.cs b/src/Components/Components/src/Routing/Router.cs
index f8925657732..fa4f421ab29 100644
--- a/src/Components/Components/src/Routing/Router.cs
+++ b/src/Components/Components/src/Routing/Router.cs
@@ -31,6 +31,8 @@ namespace Microsoft.AspNetCore.Components.Routing
 
         private CancellationTokenSource _onNavigateCts;
 
+        private Task _previousOnNavigateTask = Task.CompletedTask;
+
         private readonly HashSet<Assembly> _assemblies = new HashSet<Assembly>();
 
         private bool _onNavigateCalled = false;
@@ -112,7 +114,8 @@ namespace Microsoft.AspNetCore.Components.Routing
             if (!_onNavigateCalled)
             {
                 _onNavigateCalled = true;
-                await RunOnNavigateAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute));
+                await RunOnNavigateWithRefreshAsync(NavigationManager.ToBaseRelativePath(_locationAbsolute), isNavigationIntercepted: false);
+                return;
             }
 
             Refresh(isNavigationIntercepted: false);
@@ -122,7 +125,6 @@ namespace Microsoft.AspNetCore.Components.Routing
         public void Dispose()
         {
             NavigationManager.LocationChanged -= OnLocationChanged;
-            _onNavigateCts?.Dispose();
         }
 
         private static string StringUntilAny(string str, char[] chars)
@@ -147,7 +149,7 @@ namespace Microsoft.AspNetCore.Components.Routing
 
         }
 
-        private void Refresh(bool isNavigationIntercepted)
+        internal virtual void Refresh(bool isNavigationIntercepted)
         {
             RefreshRouteTable();
 
@@ -190,18 +192,21 @@ namespace Microsoft.AspNetCore.Components.Routing
             }
         }
 
-        private async Task RunOnNavigateAsync(string path)
+        private async ValueTask<bool> RunOnNavigateAsync(string path, Task previousOnNavigate)
         {
             // If this router instance does not provide an OnNavigateAsync parameter
             // then we render the component associated with the route as per usual.
             if (!OnNavigateAsync.HasDelegate)
             {
-                return;
+                return true;
             }
 
             // If we've already invoked a task and stored its CTS, then
-            // cancel the existing task.
-            _onNavigateCts?.Dispose();
+            // cancel that existing CTS.
+            _onNavigateCts?.Cancel();
+            // Then make sure that the task has been completed cancelled or
+            // completed before continuing with the execution of this current task.
+            await previousOnNavigate;
 
             // Create a new cancellation token source for this instance
             _onNavigateCts = new CancellationTokenSource();
@@ -209,9 +214,9 @@ namespace Microsoft.AspNetCore.Components.Routing
 
             // Create a cancellation task based on the cancellation token
             // associated with the current running task.
-            var cancellationTaskSource = new TaskCompletionSource();
+            var cancellationTcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
             navigateContext.CancellationToken.Register(state =>
-                ((TaskCompletionSource)state).SetResult(), cancellationTaskSource);
+                ((TaskCompletionSource)state).SetResult(), cancellationTcs);
 
             var task = OnNavigateAsync.InvokeAsync(navigateContext);
 
@@ -221,13 +226,34 @@ namespace Microsoft.AspNetCore.Components.Routing
                 _renderHandle.Render(Navigating);
             }
 
-            await Task.WhenAny(task, cancellationTaskSource.Task);
+            var completedTask = await Task.WhenAny(task, cancellationTcs.Task);
+            return task == completedTask;
         }
 
-        private async Task RunOnNavigateWithRefreshAsync(string path, bool isNavigationIntercepted)
+        internal async Task RunOnNavigateWithRefreshAsync(string path, bool isNavigationIntercepted)
         {
-            await RunOnNavigateAsync(path);
-            Refresh(isNavigationIntercepted);
+            // We cache the Task representing the previously invoked RunOnNavigateWithRefreshAsync
+            // that is stored
+            var previousTask = _previousOnNavigateTask;
+            // Then we create a new one that represents our current invocation and store it
+            // globally for the next invocation. Note to the developer, if the WASM runtime
+            // support multi-threading then we'll need to implement the appropriate locks
+            // here to ensure that the cached previous task is overwritten incorrectly.
+            var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+            _previousOnNavigateTask = tcs.Task;
+            try
+            {
+                // And pass an indicator for the previous task to the currently running one.
+                var shouldRefresh = await RunOnNavigateAsync(path, previousTask);
+                if (shouldRefresh)
+                {
+                    Refresh(isNavigationIntercepted);
+                }
+            }
+            finally
+            {
+                tcs.SetResult();
+            }
         }
 
         private void OnLocationChanged(object sender, LocationChangedEventArgs args)
diff --git a/src/Components/Components/test/Routing/RouterTest.cs b/src/Components/Components/test/Routing/RouterTest.cs
new file mode 100644
index 00000000000..d9476f34983
--- /dev/null
+++ b/src/Components/Components/test/Routing/RouterTest.cs
@@ -0,0 +1,104 @@
+// Copyright (c) .NET Foundation. All rights reserved.
+// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
+
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Reflection;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.Components.Routing;
+using Microsoft.AspNetCore.Components.Test.Helpers;
+using Microsoft.Extensions.DependencyModel;
+using Moq;
+using Xunit;
+
+namespace Microsoft.AspNetCore.Components.Test.Routing
+{
+    public class RouterTest
+    {
+        [Fact]
+        public async Task CanRunOnNavigateAsync()
+        {
+            // Arrange
+            var router = CreateMockRouter();
+            var called = false;
+            async Task OnNavigateAsync(NavigationContext args)
+            {
+                await Task.CompletedTask;
+                called = true;
+            }
+            router.Object.OnNavigateAsync = new EventCallbackFactory().Create<NavigationContext>(router, OnNavigateAsync);
+
+            // Act
+            await router.Object.RunOnNavigateWithRefreshAsync("http://example.com/jan", false);
+
+            // Assert
+            Assert.True(called);
+        }
+
+        [Fact]
+        public async Task CanCancelPreviousOnNavigateAsync()
+        {
+            // Arrange
+            var router = CreateMockRouter();
+            var cancelled = "";
+            async Task OnNavigateAsync(NavigationContext args)
+            {
+                await Task.CompletedTask;
+                args.CancellationToken.Register(() => cancelled = args.Path);
+            };
+            router.Object.OnNavigateAsync = new EventCallbackFactory().Create<NavigationContext>(router, OnNavigateAsync);
+
+            // Act
+            await router.Object.RunOnNavigateWithRefreshAsync("jan", false);
+            await router.Object.RunOnNavigateWithRefreshAsync("feb", false);
+
+            // Assert
+            var expected = "jan";
+            Assert.Equal(cancelled, expected);
+        }
+
+        [Fact]
+        public async Task RefreshesOnceOnCancelledOnNavigateAsync()
+        {
+            // Arrange
+            var router = CreateMockRouter();
+            async Task OnNavigateAsync(NavigationContext args)
+            {
+                if (args.Path.EndsWith("jan"))
+                {
+                    await Task.Delay(Timeout.Infinite);
+                }
+            };
+            router.Object.OnNavigateAsync = new EventCallbackFactory().Create<NavigationContext>(router, OnNavigateAsync);
+
+            // Act
+            var janTask = router.Object.RunOnNavigateWithRefreshAsync("jan", false);
+            var febTask = router.Object.RunOnNavigateWithRefreshAsync("feb", false);
+
+            var janTaskException = await Record.ExceptionAsync(() => janTask);
+            var febTaskException = await Record.ExceptionAsync(() => febTask);
+
+            // Assert neither exceution threw an exception
+            Assert.Null(janTaskException);
+            Assert.Null(febTaskException);
+            // Assert refresh should've only been called once for the second route
+            router.Verify(x => x.Refresh(false), Times.Once());
+        }
+
+        private Mock<Router> CreateMockRouter()
+        {
+            var router = new Mock<Router>() { CallBase = true };
+            router.Setup(x => x.Refresh(It.IsAny<bool>())).Verifiable();
+            return router;
+        }
+
+        [Route("jan")]
+        private class JanComponent : ComponentBase { }
+
+        [Route("feb")]
+        private class FebComponent : ComponentBase { }
+    }
+}
-- 
GitLab