Skip to content

Commit f408fa4

Browse files
dustinsoftwareDaniel15
authored andcommitted
Add optional error boundary support (#473)
* 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
1 parent fcf35f6 commit f408fa4

File tree

11 files changed

+203
-54
lines changed

11 files changed

+203
-54
lines changed

src/React.AspNet/HtmlHelperExtensions.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
* of patent rights can be found in the PATENTS file in the same directory.
88
*/
99

10+
using System;
1011
using React.Exceptions;
1112
using React.TinyIoC;
1213

@@ -55,6 +56,7 @@ private static IReactEnvironment Environment
5556
/// <param name="clientOnly">Skip rendering server-side and only output client-side initialisation code. Defaults to <c>false</c></param>
5657
/// <param name="serverOnly">Skip rendering React specific data-attributes during server side rendering. Defaults to <c>false</c></param>
5758
/// <param name="containerClass">HTML class(es) to set on the container tag</param>
59+
/// <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>
5860
/// <returns>The component's HTML</returns>
5961
public static IHtmlString React<T>(
6062
this IHtmlHelper htmlHelper,
@@ -64,7 +66,8 @@ public static IHtmlString React<T>(
6466
string containerId = null,
6567
bool clientOnly = false,
6668
bool serverOnly = false,
67-
string containerClass = null
69+
string containerClass = null,
70+
Action<Exception, string, string> exceptionHandler = null
6871
)
6972
{
7073
try
@@ -78,7 +81,7 @@ public static IHtmlString React<T>(
7881
{
7982
reactComponent.ContainerClass = containerClass;
8083
}
81-
var result = reactComponent.RenderHtml(clientOnly, serverOnly);
84+
var result = reactComponent.RenderHtml(clientOnly, serverOnly, exceptionHandler);
8285
return new HtmlString(result);
8386
}
8487
finally
@@ -100,6 +103,7 @@ public static IHtmlString React<T>(
100103
/// <param name="containerId">ID to use for the container HTML tag. Defaults to an auto-generated ID</param>
101104
/// <param name="clientOnly">Skip rendering server-side and only output client-side initialisation code. Defaults to <c>false</c></param>
102105
/// <param name="containerClass">HTML class(es) to set on the container tag</param>
106+
/// <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>
103107
/// <returns>The component's HTML</returns>
104108
public static IHtmlString ReactWithInit<T>(
105109
this IHtmlHelper htmlHelper,
@@ -108,7 +112,8 @@ public static IHtmlString ReactWithInit<T>(
108112
string htmlTag = null,
109113
string containerId = null,
110114
bool clientOnly = false,
111-
string containerClass = null
115+
string containerClass = null,
116+
Action<Exception, string, string> exceptionHandler = null
112117
)
113118
{
114119
try
@@ -122,7 +127,7 @@ public static IHtmlString ReactWithInit<T>(
122127
{
123128
reactComponent.ContainerClass = containerClass;
124129
}
125-
var html = reactComponent.RenderHtml(clientOnly);
130+
var html = reactComponent.RenderHtml(clientOnly, exceptionHandler: exceptionHandler);
126131

127132
#if LEGACYASPNET
128133
var script = new TagBuilder("script")

src/React.Core/IReactComponent.cs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/*
1+
/*
22
* Copyright (c) 2014-Present, Facebook, Inc.
33
* All rights reserved.
44
*
@@ -7,6 +7,8 @@
77
* of patent rights can be found in the PATENTS file in the same directory.
88
*/
99

10+
using System;
11+
1012
namespace React
1113
{
1214
/// <summary>
@@ -45,8 +47,9 @@ public interface IReactComponent
4547
/// </summary>
4648
/// <param name="renderContainerOnly">Only renders component container. Used for client-side only rendering.</param>
4749
/// <param name="renderServerOnly">Only renders the common HTML mark up and not any React specific data attributes. Used for server-side only rendering.</param>
50+
/// <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>
4851
/// <returns>HTML</returns>
49-
string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false);
52+
string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false, Action<Exception, string, string> exceptionHandler = null);
5053

5154
/// <summary>
5255
/// Renders the JavaScript required to initialise this component client-side. This will

src/React.Core/IReactSiteConfiguration.cs

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/*
1+
/*
22
* Copyright (c) 2014-Present, Facebook, Inc.
33
* All rights reserved.
44
*
@@ -179,5 +179,19 @@ public interface IReactSiteConfiguration
179179
/// Disables server-side rendering. This is useful when debugging your scripts.
180180
/// </summary>
181181
IReactSiteConfiguration DisableServerSideRendering();
182+
183+
/// <summary>
184+
/// An exception handler which will be called if a render exception is thrown.
185+
/// If unset, unhandled exceptions will be thrown for all component renders.
186+
/// </summary>
187+
Action<Exception, string, string> ExceptionHandler { get; set; }
188+
189+
/// <summary>
190+
/// Sets an exception handler which will be called if a render exception is thrown.
191+
/// If unset, unhandled exceptions will be thrown for all component renders.
192+
/// </summary>
193+
/// <param name="handler"></param>
194+
/// <returns></returns>
195+
IReactSiteConfiguration SetExceptionHandler(Action<Exception, string, string> handler);
182196
}
183197
}

src/React.Core/ReactComponent.cs

Lines changed: 24 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/*
1+
/*
22
* Copyright (c) 2014-Present, Facebook, Inc.
33
* All rights reserved.
44
*
@@ -107,8 +107,9 @@ public ReactComponent(IReactEnvironment environment, IReactSiteConfiguration con
107107
/// </summary>
108108
/// <param name="renderContainerOnly">Only renders component container. Used for client-side only rendering.</param>
109109
/// <param name="renderServerOnly">Only renders the common HTML mark up and not any React specific data attributes. Used for server-side only rendering.</param>
110+
/// <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>
110111
/// <returns>HTML</returns>
111-
public virtual string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false)
112+
public virtual string RenderHtml(bool renderContainerOnly = false, bool renderServerOnly = false, Action<Exception, string, string> exceptionHandler = null)
112113
{
113114
if (!_configuration.UseServerSideRendering)
114115
{
@@ -120,39 +121,39 @@ public virtual string RenderHtml(bool renderContainerOnly = false, bool renderSe
120121
EnsureComponentExists();
121122
}
122123

123-
try
124+
var html = string.Empty;
125+
if (!renderContainerOnly)
124126
{
125-
var html = string.Empty;
126-
if (!renderContainerOnly)
127+
try
127128
{
128129
var reactRenderCommand = renderServerOnly
129130
? string.Format("ReactDOMServer.renderToStaticMarkup({0})", GetComponentInitialiser())
130131
: string.Format("ReactDOMServer.renderToString({0})", GetComponentInitialiser());
131132
html = _environment.Execute<string>(reactRenderCommand);
132133
}
133-
134-
string attributes = string.Format("id=\"{0}\"", ContainerId);
135-
if (!string.IsNullOrEmpty(ContainerClass))
134+
catch (JsRuntimeException ex)
136135
{
137-
attributes += string.Format(" class=\"{0}\"", ContainerClass);
138-
}
136+
if (exceptionHandler == null)
137+
{
138+
exceptionHandler = _configuration.ExceptionHandler;
139+
}
139140

140-
return string.Format(
141-
"<{2} {0}>{1}</{2}>",
142-
attributes,
143-
html,
144-
ContainerTag
145-
);
141+
exceptionHandler(ex, ComponentName, ContainerId);
142+
}
146143
}
147-
catch (JsRuntimeException ex)
144+
145+
string attributes = string.Format("id=\"{0}\"", ContainerId);
146+
if (!string.IsNullOrEmpty(ContainerClass))
148147
{
149-
throw new ReactServerRenderingException(string.Format(
150-
"Error while rendering \"{0}\" to \"{2}\": {1}",
151-
ComponentName,
152-
ex.Message,
153-
ContainerId
154-
));
148+
attributes += string.Format(" class=\"{0}\"", ContainerClass);
155149
}
150+
151+
return string.Format(
152+
"<{2} {0}>{1}</{2}>",
153+
attributes,
154+
html,
155+
ContainerTag
156+
);
156157
}
157158

158159
/// <summary>

src/React.Core/ReactSiteConfiguration.cs

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
/*
1+
/*
22
* Copyright (c) 2014-Present, Facebook, Inc.
33
* All rights reserved.
44
*
@@ -7,9 +7,11 @@
77
* of patent rights can be found in the PATENTS file in the same directory.
88
*/
99

10-
using Newtonsoft.Json;
10+
using System;
1111
using System.Collections.Generic;
1212
using System.Linq;
13+
using Newtonsoft.Json;
14+
using React.Exceptions;
1315

1416
namespace React
1517
{
@@ -44,6 +46,13 @@ public ReactSiteConfiguration()
4446
};
4547
UseDebugReact = false;
4648
UseServerSideRendering = true;
49+
ExceptionHandler = (Exception ex, string ComponentName, string ContainerId) =>
50+
throw new ReactServerRenderingException(string.Format(
51+
"Error while rendering \"{0}\" to \"{2}\": {1}",
52+
ComponentName,
53+
ex.Message,
54+
ContainerId
55+
));
4756
}
4857

4958
/// <summary>
@@ -300,5 +309,22 @@ public IReactSiteConfiguration DisableServerSideRendering()
300309
UseServerSideRendering = false;
301310
return this;
302311
}
312+
313+
/// <summary>
314+
/// Handle an exception caught during server-render of a component.
315+
/// If unset, unhandled exceptions will be thrown for all component renders.
316+
/// </summary>
317+
public Action<Exception, string, string> ExceptionHandler { get; set; }
318+
319+
/// <summary>
320+
///
321+
/// </summary>
322+
/// <param name="handler"></param>
323+
/// <returns></returns>
324+
public IReactSiteConfiguration SetExceptionHandler(Action<Exception, string, string> handler)
325+
{
326+
ExceptionHandler = handler;
327+
return this;
328+
}
303329
}
304330
}

src/React.Sample.CoreMvc/Controllers/HomeController.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ public class IndexViewModel
3737
{
3838
public IEnumerable<CommentModel> Comments { get; set; }
3939
public int CommentsPerPage { get; set; }
40+
public bool ThrowRenderError { get; set; }
4041
}
4142
}
4243

@@ -78,7 +79,8 @@ public IActionResult Index()
7879
return View(new IndexViewModel
7980
{
8081
Comments = _comments.Take(COMMENTS_PER_PAGE),
81-
CommentsPerPage = COMMENTS_PER_PAGE
82+
CommentsPerPage = COMMENTS_PER_PAGE,
83+
ThrowRenderError = Request.Query.ContainsKey("throwRenderError"),
8284
});
8385
}
8486

src/React.Sample.CoreMvc/Startup.cs

Lines changed: 9 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@
55
* This source code is licensed under the BSD-style license found in the
66
* LICENSE file in the root directory of this source tree. An additional grant
77
* of patent rights can be found in the PATENTS file in the same directory.
8-
*/
9-
8+
*/
9+
1010
using System;
1111
using Microsoft.AspNetCore.Builder;
1212
using Microsoft.AspNetCore.Hosting;
@@ -20,15 +20,16 @@ namespace React.Sample.CoreMvc
2020
{
2121
public class Startup
2222
{
23-
public Startup(IHostingEnvironment env)
23+
public Startup(IHostingEnvironment env, ILogger<Startup> logger)
2424
{
2525
// Setup configuration sources.
2626
var builder = new ConfigurationBuilder().AddEnvironmentVariables();
27-
27+
Logger = logger;
2828
Configuration = builder.Build();
2929
}
3030

3131
public IConfiguration Configuration { get; set; }
32+
public ILogger<Startup> Logger { get; set; }
3233

3334
// This method gets called by the runtime.
3435
public IServiceProvider ConfigureServices(IServiceCollection services)
@@ -70,6 +71,10 @@ public void Configure(IApplicationBuilder app, IHostingEnvironment env, ILoggerF
7071
config
7172
.SetReuseJavaScriptEngines(true)
7273
.AddScript("~/js/Sample.jsx")
74+
.SetExceptionHandler((ex, name, id) =>
75+
{
76+
Logger.LogError("React component exception thrown!" + ex.ToString());
77+
})
7378
.SetUseDebugReact(true);
7479
});
7580

src/React.Sample.CoreMvc/Views/Home/Index.cshtml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
</p>
1515

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

1919
<!-- Load all required scripts (React + the site's scripts) -->
2020
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.0.0/umd/react.development.js"></script>

src/React.Sample.CoreMvc/wwwroot/js/Sample.jsx

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@
99

1010
class CommentsBox extends React.Component {
1111
static propTypes = {
12-
initialComments: PropTypes.array.isRequired
12+
initialComments: PropTypes.array.isRequired,
13+
throwRenderError: PropTypes.bool,
1314
};
1415

1516
state = {
@@ -53,6 +54,9 @@ class CommentsBox extends React.Component {
5354
{commentNodes}
5455
</ol>
5556
{this.renderMoreLink()}
57+
<ErrorBoundary>
58+
<ExceptionDemo throwRenderError={this.props.throwRenderError} />
59+
</ErrorBoundary>
5660
</div>
5761
);
5862
}
@@ -108,3 +112,46 @@ class Avatar extends React.Component {
108112
return 'https://avatars.githubusercontent.com/' + author.githubUsername + '?s=50';
109113
}
110114
}
115+
116+
class ErrorBoundary extends React.Component {
117+
static propTypes = {
118+
children: PropTypes.node.isRequired,
119+
};
120+
121+
state = {};
122+
123+
componentDidCatch() {
124+
this.setState({ hasCaughtException: true });
125+
}
126+
127+
render() {
128+
return this.state.hasCaughtException ? (
129+
<div>An error occurred. Please reload.</div>
130+
) : this.props.children;
131+
}
132+
}
133+
134+
class ExceptionDemo extends React.Component {
135+
static propTypes = {
136+
throwRenderError: PropTypes.bool,
137+
}
138+
139+
state = {
140+
throwRenderError: this.props.throwRenderError,
141+
};
142+
143+
onClick = () => {
144+
window.history.replaceState(null, null, window.location + '?throwRenderError');
145+
this.setState({ throwRenderError: true });
146+
}
147+
148+
render() {
149+
return (
150+
<div>
151+
<button onClick={this.onClick}>
152+
{this.state.throwRenderError ? this.state.testObject.one.two : ''}Throw exception
153+
</button>
154+
</div>
155+
);
156+
}
157+
}

0 commit comments

Comments
 (0)