diff --git a/src/React.AspNet/HtmlHelperExtensions.cs b/src/React.AspNet/HtmlHelperExtensions.cs index cb6b168c9..b136ca42f 100644 --- a/src/React.AspNet/HtmlHelperExtensions.cs +++ b/src/React.AspNet/HtmlHelperExtensions.cs @@ -7,6 +7,7 @@ * of patent rights can be found in the PATENTS file in the same directory. */ +using System; using React.Exceptions; using React.TinyIoC; @@ -55,6 +56,7 @@ private static IReactEnvironment Environment /// Skip rendering server-side and only output client-side initialisation code. Defaults to false /// Skip rendering React specific data-attributes during server side rendering. Defaults to false /// HTML class(es) to set on the container tag + /// A custom exception handler that will be called if a component throws during a render. Args: (Exception ex, string componentName, string containerId) /// The component's HTML public static IHtmlString React( this IHtmlHelper htmlHelper, @@ -64,7 +66,8 @@ public static IHtmlString React( string containerId = null, bool clientOnly = false, bool serverOnly = false, - string containerClass = null + string containerClass = null, + Action exceptionHandler = null ) { try @@ -78,7 +81,7 @@ public static IHtmlString React( { reactComponent.ContainerClass = containerClass; } - var result = reactComponent.RenderHtml(clientOnly, serverOnly); + var result = reactComponent.RenderHtml(clientOnly, serverOnly, exceptionHandler); return new HtmlString(result); } finally @@ -100,6 +103,7 @@ public static IHtmlString React( /// ID to use for the container HTML tag. Defaults to an auto-generated ID /// Skip rendering server-side and only output client-side initialisation code. Defaults to false /// HTML class(es) to set on the container tag + /// A custom exception handler that will be called if a component throws during a render. Args: (Exception ex, string componentName, string containerId) /// The component's HTML public static IHtmlString ReactWithInit( this IHtmlHelper htmlHelper, @@ -108,7 +112,8 @@ public static IHtmlString ReactWithInit( string htmlTag = null, string containerId = null, bool clientOnly = false, - string containerClass = null + string containerClass = null, + Action exceptionHandler = null ) { try @@ -122,7 +127,7 @@ public static IHtmlString ReactWithInit( { reactComponent.ContainerClass = containerClass; } - var html = reactComponent.RenderHtml(clientOnly); + var html = reactComponent.RenderHtml(clientOnly, exceptionHandler: exceptionHandler); #if LEGACYASPNET var script = new TagBuilder("script") diff --git a/src/React.Core/IReactComponent.cs b/src/React.Core/IReactComponent.cs index 04016d09a..3cdf7f794 100644 --- a/src/React.Core/IReactComponent.cs +++ b/src/React.Core/IReactComponent.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2014-Present, Facebook, Inc. * All rights reserved. * @@ -7,6 +7,8 @@ * of patent rights can be found in the PATENTS file in the same directory. */ +using System; + namespace React { /// @@ -45,8 +47,9 @@ public interface IReactComponent /// /// Only renders component container. Used for client-side only rendering. /// Only renders the common HTML mark up and not any React specific data attributes. Used for server-side only rendering. + /// A custom exception handler that will be called if a component throws during a render. Args: (Exception ex, string componentName, string containerId) /// HTML - string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false); + string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false, Action exceptionHandler = null); /// /// Renders the JavaScript required to initialise this component client-side. This will diff --git a/src/React.Core/IReactSiteConfiguration.cs b/src/React.Core/IReactSiteConfiguration.cs index bd10fee0f..8c3eed6da 100644 --- a/src/React.Core/IReactSiteConfiguration.cs +++ b/src/React.Core/IReactSiteConfiguration.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2014-Present, Facebook, Inc. * All rights reserved. * @@ -179,5 +179,19 @@ public interface IReactSiteConfiguration /// Disables server-side rendering. This is useful when debugging your scripts. /// IReactSiteConfiguration DisableServerSideRendering(); + + /// + /// An exception handler which will be called if a render exception is thrown. + /// If unset, unhandled exceptions will be thrown for all component renders. + /// + Action ExceptionHandler { get; set; } + + /// + /// Sets an exception handler which will be called if a render exception is thrown. + /// If unset, unhandled exceptions will be thrown for all component renders. + /// + /// + /// + IReactSiteConfiguration SetExceptionHandler(Action handler); } } diff --git a/src/React.Core/ReactComponent.cs b/src/React.Core/ReactComponent.cs index 43c40628c..e736604f0 100644 --- a/src/React.Core/ReactComponent.cs +++ b/src/React.Core/ReactComponent.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2014-Present, Facebook, Inc. * All rights reserved. * @@ -107,8 +107,9 @@ public ReactComponent(IReactEnvironment environment, IReactSiteConfiguration con /// /// Only renders component container. Used for client-side only rendering. /// Only renders the common HTML mark up and not any React specific data attributes. Used for server-side only rendering. + /// A custom exception handler that will be called if a component throws during a render. Args: (Exception ex, string componentName, string containerId) /// HTML - public virtual string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false) + public virtual string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false, Action exceptionHandler = null) { if (!_configuration.UseServerSideRendering) { @@ -120,39 +121,39 @@ public virtual string RenderHtml(bool renderContainerOnly = false, bool renderSe EnsureComponentExists(); } - try + var html = string.Empty; + if (!renderContainerOnly) { - var html = string.Empty; - if (!renderContainerOnly) + try { var reactRenderCommand = renderServerOnly ? string.Format("ReactDOMServer.renderToStaticMarkup({0})", GetComponentInitialiser()) : string.Format("ReactDOMServer.renderToString({0})", GetComponentInitialiser()); html = _environment.Execute(reactRenderCommand); } - - string attributes = string.Format("id=\"{0}\"", ContainerId); - if (!string.IsNullOrEmpty(ContainerClass)) + catch (JsRuntimeException ex) { - attributes += string.Format(" class=\"{0}\"", ContainerClass); - } + if (exceptionHandler == null) + { + exceptionHandler = _configuration.ExceptionHandler; + } - return string.Format( - "<{2} {0}>{1}", - attributes, - html, - ContainerTag - ); + exceptionHandler(ex, ComponentName, ContainerId); + } } - catch (JsRuntimeException ex) + + string attributes = string.Format("id=\"{0}\"", ContainerId); + if (!string.IsNullOrEmpty(ContainerClass)) { - throw new ReactServerRenderingException(string.Format( - "Error while rendering \"{0}\" to \"{2}\": {1}", - ComponentName, - ex.Message, - ContainerId - )); + attributes += string.Format(" class=\"{0}\"", ContainerClass); } + + return string.Format( + "<{2} {0}>{1}", + attributes, + html, + ContainerTag + ); } /// diff --git a/src/React.Core/ReactSiteConfiguration.cs b/src/React.Core/ReactSiteConfiguration.cs index bab2e5e23..879a92db9 100644 --- a/src/React.Core/ReactSiteConfiguration.cs +++ b/src/React.Core/ReactSiteConfiguration.cs @@ -1,4 +1,4 @@ -/* +/* * Copyright (c) 2014-Present, Facebook, Inc. * All rights reserved. * @@ -7,9 +7,11 @@ * of patent rights can be found in the PATENTS file in the same directory. */ -using Newtonsoft.Json; +using System; using System.Collections.Generic; using System.Linq; +using Newtonsoft.Json; +using React.Exceptions; namespace React { @@ -44,6 +46,13 @@ public ReactSiteConfiguration() }; UseDebugReact = false; UseServerSideRendering = true; + ExceptionHandler = (Exception ex, string ComponentName, string ContainerId) => + throw new ReactServerRenderingException(string.Format( + "Error while rendering \"{0}\" to \"{2}\": {1}", + ComponentName, + ex.Message, + ContainerId + )); } /// @@ -300,5 +309,22 @@ public IReactSiteConfiguration DisableServerSideRendering() UseServerSideRendering = false; return this; } + + /// + /// Handle an exception caught during server-render of a component. + /// If unset, unhandled exceptions will be thrown for all component renders. + /// + public Action ExceptionHandler { get; set; } + + /// + /// + /// + /// + /// + public IReactSiteConfiguration SetExceptionHandler(Action handler) + { + ExceptionHandler = handler; + return this; + } } } diff --git a/src/React.Sample.CoreMvc/Controllers/HomeController.cs b/src/React.Sample.CoreMvc/Controllers/HomeController.cs index 8dd83e224..5962890cd 100644 --- a/src/React.Sample.CoreMvc/Controllers/HomeController.cs +++ b/src/React.Sample.CoreMvc/Controllers/HomeController.cs @@ -37,6 +37,7 @@ public class IndexViewModel { public IEnumerable Comments { get; set; } public int CommentsPerPage { get; set; } + public bool ThrowRenderError { get; set; } } } @@ -78,7 +79,8 @@ public IActionResult Index() return View(new IndexViewModel { Comments = _comments.Take(COMMENTS_PER_PAGE), - CommentsPerPage = COMMENTS_PER_PAGE + CommentsPerPage = COMMENTS_PER_PAGE, + ThrowRenderError = Request.Query.ContainsKey("throwRenderError"), }); } diff --git a/src/React.Sample.CoreMvc/Startup.cs b/src/React.Sample.CoreMvc/Startup.cs index 356107449..d4b0da082 100644 --- a/src/React.Sample.CoreMvc/Startup.cs +++ b/src/React.Sample.CoreMvc/Startup.cs @@ -5,8 +5,8 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. - */ - + */ + using System; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -20,15 +20,16 @@ namespace React.Sample.CoreMvc { public class Startup { - public Startup(IHostingEnvironment env) + public Startup(IHostingEnvironment env, ILogger logger) { // Setup configuration sources. var builder = new ConfigurationBuilder().AddEnvironmentVariables(); - + Logger = logger; Configuration = builder.Build(); } public IConfiguration Configuration { get; set; } + public ILogger Logger { get; set; } // This method gets called by the runtime. public IServiceProvider ConfigureServices(IServiceCollection services) @@ -70,6 +71,10 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerF config .SetReuseJavaScriptEngines(true) .AddScript("~/js/Sample.jsx") + .SetExceptionHandler((ex, name, id) => + { + Logger.LogError("React component exception thrown!" + ex.ToString()); + }) .SetUseDebugReact(true); }); diff --git a/src/React.Sample.CoreMvc/Views/Home/Index.cshtml b/src/React.Sample.CoreMvc/Views/Home/Index.cshtml index 3f65ab4e7..6f939ad17 100644 --- a/src/React.Sample.CoreMvc/Views/Home/Index.cshtml +++ b/src/React.Sample.CoreMvc/Views/Home/Index.cshtml @@ -14,7 +14,7 @@

- @Html.React("CommentsBox", new { initialComments = Model.Comments }) + @Html.React("CommentsBox", new { initialComments = Model.Comments, ThrowRenderError = Model.ThrowRenderError }) diff --git a/src/React.Sample.CoreMvc/wwwroot/js/Sample.jsx b/src/React.Sample.CoreMvc/wwwroot/js/Sample.jsx index 80aaedbe3..6fbea2727 100644 --- a/src/React.Sample.CoreMvc/wwwroot/js/Sample.jsx +++ b/src/React.Sample.CoreMvc/wwwroot/js/Sample.jsx @@ -9,7 +9,8 @@ class CommentsBox extends React.Component { static propTypes = { - initialComments: PropTypes.array.isRequired + initialComments: PropTypes.array.isRequired, + throwRenderError: PropTypes.bool, }; state = { @@ -53,6 +54,9 @@ class CommentsBox extends React.Component { {commentNodes} {this.renderMoreLink()} + + + ); } @@ -108,3 +112,46 @@ class Avatar extends React.Component { return 'https://avatars.githubusercontent.com/' + author.githubUsername + '?s=50'; } } + +class ErrorBoundary extends React.Component { + static propTypes = { + children: PropTypes.node.isRequired, + }; + + state = {}; + + componentDidCatch() { + this.setState({ hasCaughtException: true }); + } + + render() { + return this.state.hasCaughtException ? ( +
An error occurred. Please reload.
+ ) : this.props.children; + } +} + +class ExceptionDemo extends React.Component { + static propTypes = { + throwRenderError: PropTypes.bool, + } + + state = { + throwRenderError: this.props.throwRenderError, + }; + + onClick = () => { + window.history.replaceState(null, null, window.location + '?throwRenderError'); + this.setState({ throwRenderError: true }); + } + + render() { + return ( +
+ +
+ ); + } +} diff --git a/tests/React.Tests/Core/ReactComponentTest.cs b/tests/React.Tests/Core/ReactComponentTest.cs index 8585345cf..234825b05 100644 --- a/tests/React.Tests/Core/ReactComponentTest.cs +++ b/tests/React.Tests/Core/ReactComponentTest.cs @@ -1,15 +1,17 @@ -/* +/* * Copyright (c) 2014-Present, Facebook, Inc. * All rights reserved. * * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. - */ - + */ + +using System; +using JavaScriptEngineSwitcher.Core; using Moq; -using Xunit; using React.Exceptions; +using Xunit; namespace React.Tests.Core { @@ -160,7 +162,7 @@ public void RenderJavaScriptShouldCallRenderComponent() ); } - [Theory] + [Theory] [InlineData("Foo", true)] [InlineData("Foo.Bar", true)] [InlineData("Foo.Bar.Baz", true)] @@ -192,5 +194,49 @@ public void GeneratesContainerIdIfNotProvided() Assert.StartsWith("react_", component.ContainerId); } + [Fact] + public void ExceptionThrownIsHandled() + { + var environment = new Mock(); + environment.Setup(x => x.Execute("typeof Foo !== 'undefined'")).Returns(true); + environment.Setup(x => x.Execute(@"ReactDOMServer.renderToString(React.createElement(Foo, {""hello"":""World""}))")) + .Throws(new JsRuntimeException("'undefined' is not an object")); + + var config = new Mock(); + config.Setup(x => x.UseServerSideRendering).Returns(true); + config.Setup(x => x.ExceptionHandler).Returns(() => throw new ReactServerRenderingException("test")); + + var component = new ReactComponent(environment.Object, config.Object, "Foo", "container") + { + Props = new { hello = "World" } + }; + + // Default behavior + bool exceptionCaught = false; + try + { + component.RenderHtml(); + } + catch (ReactServerRenderingException) + { + exceptionCaught = true; + } + + Assert.True(exceptionCaught); + + // Custom handler passed into render call + bool customHandlerInvoked = false; + Action customHandler = (ex, name, id) => customHandlerInvoked = true; + component.RenderHtml(exceptionHandler: customHandler); + Assert.True(customHandlerInvoked); + + // Custom exception handler set + Exception caughtException = null; + config.Setup(x => x.ExceptionHandler).Returns((ex, name, id) => caughtException = ex); + + var result = component.RenderHtml(); + Assert.Equal(@"
", result); + Assert.NotNull(caughtException); + } } } diff --git a/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs b/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs index f087fdd77..14d1253a4 100644 --- a/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs +++ b/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs @@ -5,11 +5,11 @@ * This source code is licensed under the BSD-style license found in the * LICENSE file in the root directory of this source tree. An additional grant * of patent rights can be found in the PATENTS file in the same directory. - */ - + */ + using Moq; -using Xunit; using React.Web.Mvc; +using Xunit; namespace React.Tests.Mvc { @@ -31,7 +31,7 @@ private Mock ConfigureMockEnvironment() public void ReactWithInitShouldReturnHtmlAndScript() { var component = new Mock(); - component.Setup(x => x.RenderHtml(false, false)).Returns("HTML"); + component.Setup(x => x.RenderHtml(false, false, null)).Returns("HTML"); component.Setup(x => x.RenderJavaScript()).Returns("JS"); var environment = ConfigureMockEnvironment(); environment.Setup(x => x.CreateComponent( @@ -57,7 +57,7 @@ public void ReactWithInitShouldReturnHtmlAndScript() public void EngineIsReturnedToPoolAfterRender() { var component = new Mock(); - component.Setup(x => x.RenderHtml(true, true)).Returns("HTML"); + component.Setup(x => x.RenderHtml(true, true, null)).Returns("HTML"); var environment = ConfigureMockEnvironment(); environment.Setup(x => x.CreateComponent( "ComponentName", @@ -75,7 +75,7 @@ public void EngineIsReturnedToPoolAfterRender() clientOnly: true, serverOnly: true ); - component.Verify(x => x.RenderHtml(It.Is(y => y == true), It.Is(z => z == true)), Times.Once); + component.Verify(x => x.RenderHtml(It.Is(y => y == true), It.Is(z => z == true), null), Times.Once); environment.Verify(x => x.ReturnEngineToPool(), Times.Once); } @@ -83,7 +83,7 @@ public void EngineIsReturnedToPoolAfterRender() public void ReactWithClientOnlyTrueShouldCallRenderHtmlWithTrue() { var component = new Mock(); - component.Setup(x => x.RenderHtml(true, true)).Returns("HTML"); + component.Setup(x => x.RenderHtml(true, true, null)).Returns("HTML"); var environment = ConfigureMockEnvironment(); environment.Setup(x => x.CreateComponent( "ComponentName", @@ -100,13 +100,13 @@ public void ReactWithClientOnlyTrueShouldCallRenderHtmlWithTrue() clientOnly: true, serverOnly: true ); - component.Verify(x => x.RenderHtml(It.Is(y => y == true), It.Is(z => z == true)), Times.Once); + component.Verify(x => x.RenderHtml(It.Is(y => y == true), It.Is(z => z == true), null), Times.Once); } [Fact] public void ReactWithServerOnlyTrueShouldCallRenderHtmlWithTrue() { var component = new Mock(); - component.Setup(x => x.RenderHtml(true, true)).Returns("HTML"); + component.Setup(x => x.RenderHtml(true, true, null)).Returns("HTML"); var environment = ConfigureMockEnvironment(); environment.Setup(x => x.CreateComponent( "ComponentName", @@ -123,7 +123,7 @@ public void ReactWithServerOnlyTrueShouldCallRenderHtmlWithTrue() { clientOnly: true, serverOnly: true ); - component.Verify(x => x.RenderHtml(It.Is(y => y == true), It.Is(z => z == true)), Times.Once); + component.Verify(x => x.RenderHtml(It.Is(y => y == true), It.Is(z => z == true), null), Times.Once); } } }