From b743ba2f66af839ebefa9af938cd2ce2c353b3c2 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Sun, 17 Mar 2019 20:14:44 -0700 Subject: [PATCH] Throw unhandled exceptions during prerendering Fixes: #8609 Currently exceptions thrown during prerendering are simply logged. This change uses the existing *unhandled exception* mechanism of the renderer/circuit to throw these. The result is that the developer exception page just works for prerendering. --- ...ft.AspNetCore.Components.netstandard2.0.cs | 2 +- .../EventCallbackFactoryBinderExtensions.cs | 333 +++++++++++++++--- ...ventCallbackFactoryBinderExtensionsTest.cs | 57 ++- .../Server/src/Circuits/CircuitPrerenderer.cs | 15 +- .../Server/src/Circuits/RemoteRenderer.cs | 2 + src/Components/test/E2ETest/Tests/BindTest.cs | 114 +++++- .../BasicTestApp/BindCasesComponent.cshtml | 16 +- .../ComponentRenderingFunctionalTests.cs | 48 ++- .../Controllers/RazorComponentsController.cs | 2 +- .../BasicWebSite/RazorComponents/Throws.razor | 10 + 10 files changed, 522 insertions(+), 77 deletions(-) create mode 100644 src/Mvc/test/WebSites/BasicWebSite/RazorComponents/Throws.razor diff --git a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs index e92c5c1f7d..961cc46bfc 100644 --- a/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs +++ b/src/Components/Components/ref/Microsoft.AspNetCore.Components.netstandard2.0.cs @@ -155,7 +155,7 @@ namespace Microsoft.AspNetCore.Components public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, float? existingValue) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, float existingValue) { throw null; } public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, string existingValue) { throw null; } - public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, T existingValue) where T : System.Enum { throw null; } + public static Microsoft.AspNetCore.Components.EventCallback CreateBinder(this Microsoft.AspNetCore.Components.EventCallbackFactory factory, object receiver, System.Action setter, T existingValue) where T : struct, System.Enum { throw null; } } public static partial class EventCallbackFactoryUIEventArgsExtensions { diff --git a/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs b/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs index d35eb287c1..ba0a23dd0a 100644 --- a/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs +++ b/src/Components/Components/src/EventCallbackFactoryBinderExtensions.cs @@ -11,74 +11,263 @@ namespace Microsoft.AspNetCore.Components /// public static class EventCallbackFactoryBinderExtensions { + private delegate bool BindConverter(object obj, out T value); + // Perf: conversion delegates are written as static funcs so we can prevent // allocations for these simple cases. - private static Func ConvertToString = (obj) => (string)obj; + private readonly static BindConverter ConvertToString = ConvertToStringCore; - private static Func ConvertToBool = (obj) => (bool)obj; - private static Func ConvertToNullableBool = (obj) => (bool?)obj; - - private static Func ConvertToInt = (obj) => int.Parse((string)obj); - private static Func ConvertToNullableInt = (obj) => + private static bool ConvertToStringCore(object obj, out string value) { - if (int.TryParse((string)obj, out var value)) + // We expect the input to already be a string. + value = (string)obj; + return true; + } + + private static BindConverter ConvertToBool = ConvertToBoolCore; + private static BindConverter ConvertToNullableBool = ConvertToNullableBoolCore; + + private static bool ConvertToBoolCore(object obj, out bool value) + { + // We expect the input to already be a bool. + value = (bool)obj; + return true; + } + + private static bool ConvertToNullableBoolCore(object obj, out bool? value) + { + // We expect the input to already be a bool. + value = (bool?)obj; + return true; + } + + private static BindConverter ConvertToInt = ConvertToIntCore; + private static BindConverter ConvertToNullableInt = ConvertToNullableIntCore; + + private static bool ConvertToIntCore(object obj, out int value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) { - return value; + value = default; + return false; } - return null; - }; - - private static Func ConvertToLong = (obj) => long.Parse((string)obj); - private static Func ConvertToNullableLong = (obj) => - { - if (long.TryParse((string)obj, out var value)) + if (!int.TryParse(text, out var converted)) { - return value; + value = default; + return false; } - return null; - }; + value = converted; + return true; + } - private static Func ConvertToFloat = (obj) => float.Parse((string)obj); - private static Func ConvertToNullableFloat = (obj) => + private static bool ConvertToNullableIntCore(object obj, out int? value) { - if (float.TryParse((string)obj, out var value)) + var text = (string)obj; + if (string.IsNullOrEmpty(text)) { - return value; + value = default; + return true; } - return null; - }; - - private static Func ConvertToDouble = (obj) => double.Parse((string)obj); - private static Func ConvertToNullableDouble = (obj) => - { - if (double.TryParse((string)obj, out var value)) + if (!int.TryParse(text, out var converted)) { - return value; + value = default; + return false; } - return null; - }; + value = converted; + return true; + } - private static Func ConvertToDecimal = (obj) => decimal.Parse((string)obj); - private static Func ConvertToNullableDecimal = (obj) => + private static BindConverter ConvertToLong = ConvertToLongCore; + private static BindConverter ConvertToNullableLong = ConvertToNullableLongCore; + + private static bool ConvertToLongCore(object obj, out long value) { - if (decimal.TryParse((string)obj, out var value)) + var text = (string)obj; + if (string.IsNullOrEmpty(text)) { - return value; + value = default; + return false; } - return null; - }; - - private static class EnumConverter where T : Enum - { - public static Func Convert = (obj) => + if (!long.TryParse(text, out var converted)) { - return (T)Enum.Parse(typeof(T), (string)obj); - }; + value = default; + return false; + } + + value = converted; + return true; + } + + private static bool ConvertToNullableLongCore(object obj, out long? value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return true; + } + + if (!long.TryParse(text, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; + } + + private static BindConverter ConvertToFloat = ConvertToFloatCore; + private static BindConverter ConvertToNullableFloat = ConvertToNullableFloatCore; + + private static bool ConvertToFloatCore(object obj, out float value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return false; + } + + if (!float.TryParse(text, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; + } + + private static bool ConvertToNullableFloatCore(object obj, out float? value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return true; + } + + if (!float.TryParse(text, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; + } + + private static BindConverter ConvertToDouble = ConvertToDoubleCore; + private static BindConverter ConvertToNullableDouble = ConvertToNullableDoubleCore; + + private static bool ConvertToDoubleCore(object obj, out double value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return false; + } + + if (!double.TryParse(text, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; + } + + private static bool ConvertToNullableDoubleCore(object obj, out double? value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return true; + } + + if (!double.TryParse(text, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; + } + + private static BindConverter ConvertToDecimal = ConvertToDecimalCore; + private static BindConverter ConvertToNullableDecimal = ConvertToNullableDecimalCore; + + private static bool ConvertToDecimalCore(object obj, out decimal value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return false; + } + + if (!decimal.TryParse(text, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; + } + + private static bool ConvertToNullableDecimalCore(object obj, out decimal? value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return true; + } + + if (!decimal.TryParse(text, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; + } + + private static class EnumConverter where T : struct, Enum + { + public static readonly BindConverter Convert = ConvertCore; + + public static bool ConvertCore(object obj, out T value) + { + var text = (string)obj; + if (string.IsNullOrEmpty(text)) + { + value = default; + return true; + } + + if (!Enum.TryParse(text, out var converted)) + { + value = default; + return false; + } + + value = converted; + return true; + } } /// @@ -330,7 +519,22 @@ namespace Microsoft.AspNetCore.Components // when a format is used. Action callback = (e) => { - setter(ConvertDateTime(e.Value, format: null)); + DateTime value = default; + var converted = false; + try + { + value = ConvertDateTime(e.Value, format: null); + converted = true; + } + catch + { + } + + // See comments in CreateBinderCore + if (converted) + { + setter(value); + } }; return factory.Create(receiver, callback); } @@ -355,7 +559,22 @@ namespace Microsoft.AspNetCore.Components // when a format is used. Action callback = (e) => { - setter(ConvertDateTime(e.Value, format)); + DateTime value = default; + var converted = false; + try + { + value = ConvertDateTime(e.Value, format); + converted = true; + } + catch + { + } + + // See comments in CreateBinderCore + if (converted) + { + setter(value); + } }; return factory.Create(receiver, callback); } @@ -373,7 +592,7 @@ namespace Microsoft.AspNetCore.Components this EventCallbackFactory factory, object receiver, Action setter, - T existingValue) where T : Enum + T existingValue) where T : struct, Enum { return CreateBinderCore(factory, receiver, setter, EnumConverter.Convert); } @@ -399,11 +618,27 @@ namespace Microsoft.AspNetCore.Components this EventCallbackFactory factory, object receiver, Action setter, - Func converter) + BindConverter converter) { Action callback = e => { - setter(converter(e.Value)); + T value = default; + var converted = false; + try + { + converted = converter(e.Value, out value); + } + catch + { + } + + // We only invoke the setter if the conversion didn't throw. This is valuable because it allows us to attempt + // to process invalid input but avoid dirtying the state of the component if can't be converted. Imagine if + // we assigned default(T) on failure - this would result in trouncing the user's typed in value. + if (converted) + { + setter(value); + } }; return factory.Create(receiver, callback); } diff --git a/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs b/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs index c8dd5fc4d3..6779401282 100644 --- a/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs +++ b/src/Components/Components/test/EventCallbackFactoryBinderExtensionsTest.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Components public class EventCallbackFactoryBinderExtensionsTest { [Fact] - public async Task CreateBinder_ThrowsConversionException() + public async Task CreateBinder_SwallowsConversionException() { // Arrange var value = 17; @@ -20,12 +20,61 @@ namespace Microsoft.AspNetCore.Components var binder = EventCallback.Factory.CreateBinder(component, setter, value); // Act - await Assert.ThrowsAsync(() => + await binder.InvokeAsync(new UIChangeEventArgs() { Value = "not-an-integer!", }); + + Assert.Equal(17, value); // Setter not called + Assert.Equal(1, component.Count); + } + + [Fact] + public async Task CreateBinder_ThrowsSetterException() + { + // Arrange + var component = new EventCountingComponent(); + Action setter = (_) => { throw new InvalidTimeZoneException(); }; + + var binder = EventCallback.Factory.CreateBinder(component, setter, 17); + + // Act + await Assert.ThrowsAsync(() => { - return binder.InvokeAsync(new UIChangeEventArgs() { Value = "not-an-integer!", }); + return binder.InvokeAsync(new UIChangeEventArgs() { Value = "18", }); }); - Assert.Equal(17, value); + Assert.Equal(1, component.Count); + } + + [Fact] + public async Task CreateBinder_BindsEmpty_DoesNotCallSetter() + { + // Arrange + var value = 17; + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + + var binder = EventCallback.Factory.CreateBinder(component, setter, value); + + // Act + await binder.InvokeAsync(new UIChangeEventArgs() { Value = "not-an-integer!", }); + + Assert.Equal(17, value); // Setter not called + Assert.Equal(1, component.Count); + } + + [Fact] + public async Task CreateBinder_BindsEmpty_CallsSetterForNullable() + { + // Arrange + var value = (int?)17; + var component = new EventCountingComponent(); + Action setter = (_) => value = _; + + var binder = EventCallback.Factory.CreateBinder(component, setter, value); + + // Act + await binder.InvokeAsync(new UIChangeEventArgs() { Value = "", }); + + Assert.Null(value); // Setter called Assert.Equal(1, component.Count); } diff --git a/src/Components/Server/src/Circuits/CircuitPrerenderer.cs b/src/Components/Server/src/Circuits/CircuitPrerenderer.cs index 9b05398c3d..bd1ea15ef7 100644 --- a/src/Components/Server/src/Circuits/CircuitPrerenderer.cs +++ b/src/Components/Server/src/Circuits/CircuitPrerenderer.cs @@ -1,7 +1,9 @@ // 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.Runtime.ExceptionServices; using System.Threading.Tasks; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http.Extensions; @@ -26,8 +28,12 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits GetFullUri(context.Request), GetFullBaseUri(context.Request)); + // We don't need to unsubscribe because the circuit host object is scoped to this call. + circuitHost.UnhandledException += CircuitHost_UnhandledException; + // For right now we just do prerendering and dispose the circuit. In the future we will keep the circuit around and - // reconnect to it from the ComponentsHub. + // reconnect to it from the ComponentsHub. If we keep the circuit/renderer we also need to unsubscribe this error + // handler. try { return await circuitHost.PrerenderComponentAsync( @@ -40,6 +46,13 @@ namespace Microsoft.AspNetCore.Components.Server.Circuits } } + private void CircuitHost_UnhandledException(object sender, UnhandledExceptionEventArgs e) + { + // Throw all exceptions encountered during pre-rendering so the default developer + // error page can respond. + ExceptionDispatchInfo.Capture((Exception)e.ExceptionObject).Throw(); + } + private string GetFullUri(HttpRequest request) { return UriHelper.BuildAbsolute( diff --git a/src/Components/Server/src/Circuits/RemoteRenderer.cs b/src/Components/Server/src/Circuits/RemoteRenderer.cs index e515cdbea6..9edd2e791a 100644 --- a/src/Components/Server/src/Circuits/RemoteRenderer.cs +++ b/src/Components/Server/src/Circuits/RemoteRenderer.cs @@ -94,6 +94,8 @@ namespace Microsoft.AspNetCore.Components.Browser.Rendering { Log.UnhandledExceptionRenderingComponent(_logger, exception); } + + UnhandledException?.Invoke(this, exception); } /// diff --git a/src/Components/test/E2ETest/Tests/BindTest.cs b/src/Components/test/E2ETest/Tests/BindTest.cs index 83756c3b78..832837840d 100644 --- a/src/Components/test/E2ETest/Tests/BindTest.cs +++ b/src/Components/test/E2ETest/Tests/BindTest.cs @@ -199,8 +199,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Assert.Equal("-42", boundValue.Text); Assert.Equal("-42", mirrorValue.GetAttribute("value")); - // Modify target; verify value is updated and that textboxes linked to the same data are updated + // Modify target; value is not updated because it's not convertable. target.Clear(); + Browser.Equal("-42", () => boundValue.Text); + Assert.Equal("-42", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated target.SendKeys("42\t"); Browser.Equal("42", () => boundValue.Text); Assert.Equal("42", mirrorValue.GetAttribute("value")); @@ -218,6 +222,10 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests // Modify target; verify value is updated and that textboxes linked to the same data are updated target.Clear(); + Browser.Equal("", () => boundValue.Text); + Assert.Equal("", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated target.SendKeys("-42\t"); Browser.Equal("-42", () => boundValue.Text); Assert.Equal("-42", mirrorValue.GetAttribute("value")); @@ -245,8 +253,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Assert.Equal("3000000000", boundValue.Text); Assert.Equal("3000000000", mirrorValue.GetAttribute("value")); - // Modify target; verify value is updated and that textboxes linked to the same data are updated + // Modify target; value is not updated because it's not convertable. target.Clear(); + Browser.Equal("3000000000", () => boundValue.Text); + Assert.Equal("3000000000", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated target.SendKeys("-3000000000\t"); Browser.Equal("-3000000000", () => boundValue.Text); Assert.Equal("-3000000000", mirrorValue.GetAttribute("value")); @@ -264,6 +276,10 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests // Modify target; verify value is updated and that textboxes linked to the same data are updated target.Clear(); + Browser.Equal("", () => boundValue.Text); + Assert.Equal("", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated target.SendKeys("3000000000\t"); Browser.Equal("3000000000", () => boundValue.Text); Assert.Equal("3000000000", mirrorValue.GetAttribute("value")); @@ -291,8 +307,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Assert.Equal("3.141", boundValue.Text); Assert.Equal("3.141", mirrorValue.GetAttribute("value")); - // Modify target; verify value is updated and that textboxes linked to the same data are updated + // Modify target; value is not updated because it's not convertable. target.Clear(); + Browser.Equal("3.141", () => boundValue.Text); + Assert.Equal("3.141", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated target.SendKeys("-3.141\t"); Browser.Equal("-3.141", () => boundValue.Text); Assert.Equal("-3.141", mirrorValue.GetAttribute("value")); @@ -310,6 +330,10 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests // Modify target; verify value is updated and that textboxes linked to the same data are updated target.Clear(); + Browser.Equal("", () => boundValue.Text); + Assert.Equal("", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated target.SendKeys("3.141\t"); Browser.Equal("3.141", () => boundValue.Text); Assert.Equal("3.141", mirrorValue.GetAttribute("value")); @@ -337,8 +361,12 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Assert.Equal("3.14159265359", boundValue.Text); Assert.Equal("3.14159265359", mirrorValue.GetAttribute("value")); - // Modify target; verify value is updated and that textboxes linked to the same data are updated + // Modify target; value is not updated because it's not convertable. target.Clear(); + Browser.Equal("3.14159265359", () => boundValue.Text); + Assert.Equal("3.14159265359", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated target.SendKeys("-3.14159265359\t"); Browser.Equal("-3.14159265359", () => boundValue.Text); Assert.Equal("-3.14159265359", mirrorValue.GetAttribute("value")); @@ -363,6 +391,10 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests // Modify target; verify value is updated and that textboxes linked to the same data are updated target.Clear(); + Browser.Equal("", () => boundValue.Text); + Assert.Equal("", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated target.SendKeys("3.14159265359\t"); Browser.Equal("3.14159265359", () => boundValue.Text); Assert.Equal("3.14159265359", mirrorValue.GetAttribute("value")); @@ -397,9 +429,13 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Assert.Equal("0.0000000000000000000000000001", boundValue.Text); Assert.Equal("0.0000000000000000000000000001", mirrorValue.GetAttribute("value")); + // Modify target; value is not updated because it's not convertable. + target.Clear(); + Browser.Equal("0.0000000000000000000000000001", () => boundValue.Text); + Assert.Equal("0.0000000000000000000000000001", mirrorValue.GetAttribute("value")); + // Modify target; verify value is updated and that textboxes linked to the same data are updated // Decimal should preserve trailing zeros - target.Clear(); target.SendKeys("0.010\t"); Browser.Equal("0.010", () => boundValue.Text); Assert.Equal("0.010", mirrorValue.GetAttribute("value")); @@ -417,6 +453,10 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests // Modify target; verify value is updated and that textboxes linked to the same data are updated target.Clear(); + Browser.Equal("", () => boundValue.Text); + Assert.Equal("", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated target.SendKeys("0.0000000000000000000000000001\t"); Browser.Equal("0.0000000000000000000000000001", () => boundValue.Text); Assert.Equal("0.0000000000000000000000000001", mirrorValue.GetAttribute("value")); @@ -434,5 +474,69 @@ namespace Microsoft.AspNetCore.Components.E2ETest.Tests Browser.Equal(string.Empty, () => boundValue.Text); Assert.Equal(string.Empty, mirrorValue.GetAttribute("value")); } + + // This tests what happens you put invalid (unconvertable) input in. This is separate from the + // other tests because it requires type="text" - the other tests use type="number" + [Fact] + public void CanBindTextbox_Decimal_InvalidInput() + { + var target = Browser.FindElement(By.Id("textbox-decimal-invalid")); + var boundValue = Browser.FindElement(By.Id("textbox-decimal-invalid-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-decimal-invalid-mirror")); + Assert.Equal("0.0000000000000000000000000001", target.GetAttribute("value")); + Assert.Equal("0.0000000000000000000000000001", boundValue.Text); + Assert.Equal("0.0000000000000000000000000001", mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.Clear(); + target.SendKeys("0.01\t"); + Browser.Equal("0.01", () => boundValue.Text); + Assert.Equal("0.01", mirrorValue.GetAttribute("value")); + + // Modify target to something invalid - the invalid value is preserved in the input, the other displays + // don't change and still have the last value valid. + target.SendKeys("A\t"); + Browser.Equal("0.01", () => boundValue.Text); + Assert.Equal("0.01", mirrorValue.GetAttribute("value")); + Assert.Equal("0.01A", target.GetAttribute("value")); + + // Modify target to something valid. + target.SendKeys(Keys.Backspace); + target.SendKeys("1\t"); + Browser.Equal("0.011", () => boundValue.Text); + Assert.Equal("0.011", mirrorValue.GetAttribute("value")); + } + + // This tests what happens you put invalid (unconvertable) input in. This is separate from the + // other tests because it requires type="text" - the other tests use type="number" + [Fact] + public void CanBindTextbox_NullableDecimal_InvalidInput() + { + var target = Browser.FindElement(By.Id("textbox-nullable-decimal-invalid")); + var boundValue = Browser.FindElement(By.Id("textbox-nullable-decimal-invalid-value")); + var mirrorValue = Browser.FindElement(By.Id("textbox-nullable-decimal-invalid-mirror")); + Assert.Equal(string.Empty, target.GetAttribute("value")); + Assert.Equal(string.Empty, boundValue.Text); + Assert.Equal(string.Empty, mirrorValue.GetAttribute("value")); + + // Modify target; verify value is updated and that textboxes linked to the same data are updated + target.Clear(); + target.SendKeys("0.01\t"); + Browser.Equal("0.01", () => boundValue.Text); + Assert.Equal("0.01", mirrorValue.GetAttribute("value")); + + // Modify target to something invalid - the invalid value is preserved in the input, the other displays + // don't change and still have the last value valid. + target.SendKeys("A\t"); + Browser.Equal("0.01", () => boundValue.Text); + Assert.Equal("0.01", mirrorValue.GetAttribute("value")); + Assert.Equal("0.01A", target.GetAttribute("value")); + + // Modify target to something valid. + target.SendKeys(Keys.Backspace); + target.SendKeys("1\t"); + Browser.Equal("0.011", () => boundValue.Text); + Assert.Equal("0.011", mirrorValue.GetAttribute("value")); + } } } diff --git a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.cshtml b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.cshtml index 696e24568e..37ab4d2e6f 100644 --- a/src/Components/test/testassets/BasicTestApp/BindCasesComponent.cshtml +++ b/src/Components/test/testassets/BasicTestApp/BindCasesComponent.cshtml @@ -75,6 +75,18 @@ @textboxNullableDecimalValue

+

+ decimal (invalid-input): + + @textboxDecimalInvalidValue + +

+

+ Nullable decimal (invalid-input): + + @textboxNullableDecimalInvalidValue + +

Text Area

@@ -116,7 +128,7 @@ @if (includeFourthOption) { - + } @selectValue @@ -144,6 +156,8 @@ double? textboxNullableDoubleValue = null; decimal textboxDecimalValue = 0.0000000000000000000000000001M; decimal? textboxNullableDecimalValue = null; + decimal textboxDecimalInvalidValue = 0.0000000000000000000000000001M; + decimal? textboxNullableDecimalInvalidValue = null; bool includeFourthOption = false; enum SelectableValue { First, Second, Third, Fourth } diff --git a/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs b/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs index dbbdd61985..53f7f24221 100644 --- a/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs +++ b/src/Mvc/test/Mvc.FunctionalTests/ComponentRenderingFunctionalTests.cs @@ -1,12 +1,14 @@ // 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.Net; using System.Net.Http; using System.Threading.Tasks; using AngleSharp.Parser.Html; using BasicWebSite; using BasicWebSite.Services; +using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.DependencyInjection; using Xunit; @@ -17,18 +19,17 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public ComponentRenderingFunctionalTests(MvcTestFixture fixture) { Factory = fixture; - Client = Client ?? CreateClient(fixture); } - public HttpClient Client { get; } - public MvcTestFixture Factory { get; } [Fact] public async Task Renders_BasicComponent() { // Arrange & Act - var response = await Client.GetAsync("http://localhost/components"); + var client = CreateClient(Factory); + + var response = await client.GetAsync("http://localhost/components"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -41,9 +42,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public async Task Renders_BasicComponent_UsingRazorComponents_Prerrenderer() { // Arrange & Act - var client = Factory - .WithWebHostBuilder(builder => builder.ConfigureServices(services => services.AddRazorComponents())) - .CreateClient(); + var client = CreateClient(Factory, builder => builder.ConfigureServices(services => services.AddRazorComponents())); var response = await client.GetAsync("http://localhost/components"); @@ -58,7 +57,9 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public async Task Renders_RoutingComponent() { // Arrange & Act - var response = await Client.GetAsync("http://localhost/components/routable"); + var client = CreateClient(Factory, builder => builder.ConfigureServices(services => services.AddRazorComponents())); + + var response = await client.GetAsync("http://localhost/components/routable"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -71,9 +72,7 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests public async Task Renders_RoutingComponent_UsingRazorComponents_Prerrenderer() { // Arrange & Act - var client = Factory - .WithWebHostBuilder(builder => builder.ConfigureServices(services => services.AddRazorComponents())) - .CreateClient(); + var client = CreateClient(Factory, builder => builder.ConfigureServices(services => services.AddRazorComponents())); var response = await client.GetAsync("http://localhost/components/routable"); @@ -84,6 +83,21 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests AssertComponent("\n Router component\n

Routed successfully

\n", "Routing", content); } + [Fact] + public async Task Renders_ThrowingComponent_UsingRazorComponents_Prerrenderer() + { + // Arrange & Act + var client = CreateClient(Factory, builder => builder.ConfigureServices(services => services.AddRazorComponents())); + + var response = await client.GetAsync("http://localhost/components/throws"); + + // Assert + Assert.Equal(HttpStatusCode.InternalServerError, response.StatusCode); + var content = await response.Content.ReadAsStringAsync(); + + Assert.Contains("InvalidTimeZoneException: test", content); + } + [Fact] public async Task Renders_AsyncComponent() { @@ -138,8 +152,8 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests "; - - var response = await Client.GetAsync("http://localhost/components"); + var client = CreateClient(Factory); + var response = await client.GetAsync("http://localhost/components"); // Assert Assert.Equal(HttpStatusCode.OK, response.StatusCode); @@ -164,12 +178,16 @@ namespace Microsoft.AspNetCore.Mvc.FunctionalTests { } - private HttpClient CreateClient(MvcTestFixture fixture) + private HttpClient CreateClient(MvcTestFixture fixture, Action configure = null) { var loopHandler = new LoopHttpHandler(); var client = fixture - .WithWebHostBuilder(builder => builder.ConfigureServices(ConfigureTestWeatherForecastService)) + .WithWebHostBuilder(builder => + { + configure?.Invoke(builder); + builder.ConfigureServices(ConfigureTestWeatherForecastService); + }) .CreateClient(); // We configure the inner handler with a handler to this TestServer instance so that calls to the diff --git a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RazorComponentsController.cs b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RazorComponentsController.cs index c499c5f345..28aab986a3 100644 --- a/src/Mvc/test/WebSites/BasicWebSite/Controllers/RazorComponentsController.cs +++ b/src/Mvc/test/WebSites/BasicWebSite/Controllers/RazorComponentsController.cs @@ -51,7 +51,7 @@ namespace BasicWebSite.Controllers }; [HttpGet("/components")] - [HttpGet("/components/routable")] + [HttpGet("/components/{component}")] public IActionResult Index() { return View(); diff --git a/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/Throws.razor b/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/Throws.razor new file mode 100644 index 0000000000..3291fba732 --- /dev/null +++ b/src/Mvc/test/WebSites/BasicWebSite/RazorComponents/Throws.razor @@ -0,0 +1,10 @@ +@page "/components/throws" + +@* This is expected to throw and result in a 500 *@ +@functions { + protected override async Task OnInitAsync() + { + await base.OnInitAsync(); + throw new InvalidTimeZoneException("test"); + } +} \ No newline at end of file