Skip to content

Commit

Permalink
Add optional error boundary support (#473)
Browse files Browse the repository at this point in the history
* Add support for exception handling during component render

* Add tests and update sample

* Fix newline/whitespace issues

* Support component-level exception handlers

* Document exceptionHandler arguments
  • Loading branch information
dustinsoftware authored and Daniel15 committed Jan 11, 2018
1 parent fcf35f6 commit f408fa4
Show file tree
Hide file tree
Showing 11 changed files with 203 additions and 54 deletions.
13 changes: 9 additions & 4 deletions src/React.AspNet/HtmlHelperExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -55,6 +56,7 @@ private static IReactEnvironment Environment
/// <param name="clientOnly">Skip rendering server-side and only output client-side initialisation code. Defaults to <c>false</c></param>
/// <param name="serverOnly">Skip rendering React specific data-attributes during server side rendering. Defaults to <c>false</c></param>
/// <param name="containerClass">HTML class(es) to set on the container tag</param>
/// <param name="exceptionHandler">A custom exception handler that will be called if a component throws during a render. Args: (Exception ex, string componentName, string containerId)</param>
/// <returns>The component's HTML</returns>
public static IHtmlString React<T>(
this IHtmlHelper htmlHelper,
Expand All @@ -64,7 +66,8 @@ public static IHtmlString React<T>(
string containerId = null,
bool clientOnly = false,
bool serverOnly = false,
string containerClass = null
string containerClass = null,
Action<Exception, string, string> exceptionHandler = null
)
{
try
Expand All @@ -78,7 +81,7 @@ public static IHtmlString React<T>(
{
reactComponent.ContainerClass = containerClass;
}
var result = reactComponent.RenderHtml(clientOnly, serverOnly);
var result = reactComponent.RenderHtml(clientOnly, serverOnly, exceptionHandler);
return new HtmlString(result);
}
finally
Expand All @@ -100,6 +103,7 @@ public static IHtmlString React<T>(
/// <param name="containerId">ID to use for the container HTML tag. Defaults to an auto-generated ID</param>
/// <param name="clientOnly">Skip rendering server-side and only output client-side initialisation code. Defaults to <c>false</c></param>
/// <param name="containerClass">HTML class(es) to set on the container tag</param>
/// <param name="exceptionHandler">A custom exception handler that will be called if a component throws during a render. Args: (Exception ex, string componentName, string containerId)</param>
/// <returns>The component's HTML</returns>
public static IHtmlString ReactWithInit<T>(
this IHtmlHelper htmlHelper,
Expand All @@ -108,7 +112,8 @@ public static IHtmlString ReactWithInit<T>(
string htmlTag = null,
string containerId = null,
bool clientOnly = false,
string containerClass = null
string containerClass = null,
Action<Exception, string, string> exceptionHandler = null
)
{
try
Expand All @@ -122,7 +127,7 @@ public static IHtmlString ReactWithInit<T>(
{
reactComponent.ContainerClass = containerClass;
}
var html = reactComponent.RenderHtml(clientOnly);
var html = reactComponent.RenderHtml(clientOnly, exceptionHandler: exceptionHandler);

#if LEGACYASPNET
var script = new TagBuilder("script")
Expand Down
7 changes: 5 additions & 2 deletions src/React.Core/IReactComponent.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*
/*
* Copyright (c) 2014-Present, Facebook, Inc.
* All rights reserved.
*
Expand All @@ -7,6 +7,8 @@
* of patent rights can be found in the PATENTS file in the same directory.
*/

using System;

namespace React
{
/// <summary>
Expand Down Expand Up @@ -45,8 +47,9 @@ public interface IReactComponent
/// </summary>
/// <param name="renderContainerOnly">Only renders component container. Used for client-side only rendering.</param>
/// <param name="renderServerOnly">Only renders the common HTML mark up and not any React specific data attributes. Used for server-side only rendering.</param>
/// <param name="exceptionHandler">A custom exception handler that will be called if a component throws during a render. Args: (Exception ex, string componentName, string containerId)</param>
/// <returns>HTML</returns>
string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false);
string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false, Action<Exception, string, string> exceptionHandler = null);

/// <summary>
/// Renders the JavaScript required to initialise this component client-side. This will
Expand Down
16 changes: 15 additions & 1 deletion src/React.Core/IReactSiteConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*
/*
* Copyright (c) 2014-Present, Facebook, Inc.
* All rights reserved.
*
Expand Down Expand Up @@ -179,5 +179,19 @@ public interface IReactSiteConfiguration
/// Disables server-side rendering. This is useful when debugging your scripts.
/// </summary>
IReactSiteConfiguration DisableServerSideRendering();

/// <summary>
/// An exception handler which will be called if a render exception is thrown.
/// If unset, unhandled exceptions will be thrown for all component renders.
/// </summary>
Action<Exception, string, string> ExceptionHandler { get; set; }

/// <summary>
/// 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.
/// </summary>
/// <param name="handler"></param>
/// <returns></returns>
IReactSiteConfiguration SetExceptionHandler(Action<Exception, string, string> handler);
}
}
47 changes: 24 additions & 23 deletions src/React.Core/ReactComponent.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*
/*
* Copyright (c) 2014-Present, Facebook, Inc.
* All rights reserved.
*
Expand Down Expand Up @@ -107,8 +107,9 @@ public ReactComponent(IReactEnvironment environment, IReactSiteConfiguration con
/// </summary>
/// <param name="renderContainerOnly">Only renders component container. Used for client-side only rendering.</param>
/// <param name="renderServerOnly">Only renders the common HTML mark up and not any React specific data attributes. Used for server-side only rendering.</param>
/// <param name="exceptionHandler">A custom exception handler that will be called if a component throws during a render. Args: (Exception ex, string componentName, string containerId)</param>
/// <returns>HTML</returns>
public virtual string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false)
public virtual string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false, Action<Exception, string, string> exceptionHandler = null)
{
if (!_configuration.UseServerSideRendering)
{
Expand All @@ -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<string>(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}</{2}>",
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}</{2}>",
attributes,
html,
ContainerTag
);
}

/// <summary>
Expand Down
30 changes: 28 additions & 2 deletions src/React.Core/ReactSiteConfiguration.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
/*
/*
* Copyright (c) 2014-Present, Facebook, Inc.
* All rights reserved.
*
Expand All @@ -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
{
Expand Down Expand Up @@ -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
));
}

/// <summary>
Expand Down Expand Up @@ -300,5 +309,22 @@ public IReactSiteConfiguration DisableServerSideRendering()
UseServerSideRendering = false;
return this;
}

/// <summary>
/// Handle an exception caught during server-render of a component.
/// If unset, unhandled exceptions will be thrown for all component renders.
/// </summary>
public Action<Exception, string, string> ExceptionHandler { get; set; }

/// <summary>
///
/// </summary>
/// <param name="handler"></param>
/// <returns></returns>
public IReactSiteConfiguration SetExceptionHandler(Action<Exception, string, string> handler)
{
ExceptionHandler = handler;
return this;
}
}
}
4 changes: 3 additions & 1 deletion src/React.Sample.CoreMvc/Controllers/HomeController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ public class IndexViewModel
{
public IEnumerable<CommentModel> Comments { get; set; }
public int CommentsPerPage { get; set; }
public bool ThrowRenderError { get; set; }
}
}

Expand Down Expand Up @@ -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"),
});
}

Expand Down
13 changes: 9 additions & 4 deletions src/React.Sample.CoreMvc/Startup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -20,15 +20,16 @@ namespace React.Sample.CoreMvc
{
public class Startup
{
public Startup(IHostingEnvironment env)
public Startup(IHostingEnvironment env, ILogger<Startup> logger)
{
// Setup configuration sources.
var builder = new ConfigurationBuilder().AddEnvironmentVariables();

Logger = logger;
Configuration = builder.Build();
}

public IConfiguration Configuration { get; set; }
public ILogger<Startup> Logger { get; set; }

// This method gets called by the runtime.
public IServiceProvider ConfigureServices(IServiceCollection services)
Expand Down Expand Up @@ -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);
});

Expand Down
2 changes: 1 addition & 1 deletion src/React.Sample.CoreMvc/Views/Home/Index.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</p>

<!-- Render the component server-side, passing initial props -->
@Html.React("CommentsBox", new { initialComments = Model.Comments })
@Html.React("CommentsBox", new { initialComments = Model.Comments, ThrowRenderError = Model.ThrowRenderError })

<!-- Load all required scripts (React + the site's scripts) -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.0.0/umd/react.development.js"></script>
Expand Down
49 changes: 48 additions & 1 deletion src/React.Sample.CoreMvc/wwwroot/js/Sample.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@

class CommentsBox extends React.Component {
static propTypes = {
initialComments: PropTypes.array.isRequired
initialComments: PropTypes.array.isRequired,
throwRenderError: PropTypes.bool,
};

state = {
Expand Down Expand Up @@ -53,6 +54,9 @@ class CommentsBox extends React.Component {
{commentNodes}
</ol>
{this.renderMoreLink()}
<ErrorBoundary>
<ExceptionDemo throwRenderError={this.props.throwRenderError} />
</ErrorBoundary>
</div>
);
}
Expand Down Expand Up @@ -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 ? (
<div>An error occurred. Please reload.</div>
) : 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 (
<div>
<button onClick={this.onClick}>
{this.state.throwRenderError ? this.state.testObject.one.two : ''}Throw exception
</button>
</div>
);
}
}
Loading

0 comments on commit f408fa4

Please sign in to comment.