Skip to content

Commit f408fa4

Browse files
dustinsoftwareDaniel15
authored andcommittedJan 11, 2018
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

+9-4
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

+5-2
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

+15-1
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

+24-23
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

+28-2
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

+3-1
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

+9-4
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

+1-1
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

+48-1
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+
}

‎tests/React.Tests/Core/ReactComponentTest.cs

+51-5
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,17 @@
1-
/*
1+
/*
22
* Copyright (c) 2014-Present, Facebook, Inc.
33
* All rights reserved.
44
*
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+
10+
using System;
11+
using JavaScriptEngineSwitcher.Core;
1012
using Moq;
11-
using Xunit;
1213
using React.Exceptions;
14+
using Xunit;
1315

1416
namespace React.Tests.Core
1517
{
@@ -160,7 +162,7 @@ public void RenderJavaScriptShouldCallRenderComponent()
160162
);
161163
}
162164

163-
[Theory]
165+
[Theory]
164166
[InlineData("Foo", true)]
165167
[InlineData("Foo.Bar", true)]
166168
[InlineData("Foo.Bar.Baz", true)]
@@ -192,5 +194,49 @@ public void GeneratesContainerIdIfNotProvided()
192194
Assert.StartsWith("react_", component.ContainerId);
193195
}
194196

197+
[Fact]
198+
public void ExceptionThrownIsHandled()
199+
{
200+
var environment = new Mock<IReactEnvironment>();
201+
environment.Setup(x => x.Execute<bool>("typeof Foo !== 'undefined'")).Returns(true);
202+
environment.Setup(x => x.Execute<string>(@"ReactDOMServer.renderToString(React.createElement(Foo, {""hello"":""World""}))"))
203+
.Throws(new JsRuntimeException("'undefined' is not an object"));
204+
205+
var config = new Mock<IReactSiteConfiguration>();
206+
config.Setup(x => x.UseServerSideRendering).Returns(true);
207+
config.Setup(x => x.ExceptionHandler).Returns(() => throw new ReactServerRenderingException("test"));
208+
209+
var component = new ReactComponent(environment.Object, config.Object, "Foo", "container")
210+
{
211+
Props = new { hello = "World" }
212+
};
213+
214+
// Default behavior
215+
bool exceptionCaught = false;
216+
try
217+
{
218+
component.RenderHtml();
219+
}
220+
catch (ReactServerRenderingException)
221+
{
222+
exceptionCaught = true;
223+
}
224+
225+
Assert.True(exceptionCaught);
226+
227+
// Custom handler passed into render call
228+
bool customHandlerInvoked = false;
229+
Action<Exception, string, string> customHandler = (ex, name, id) => customHandlerInvoked = true;
230+
component.RenderHtml(exceptionHandler: customHandler);
231+
Assert.True(customHandlerInvoked);
232+
233+
// Custom exception handler set
234+
Exception caughtException = null;
235+
config.Setup(x => x.ExceptionHandler).Returns((ex, name, id) => caughtException = ex);
236+
237+
var result = component.RenderHtml();
238+
Assert.Equal(@"<div id=""container""></div>", result);
239+
Assert.NotNull(caughtException);
240+
}
195241
}
196242
}

‎tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs

+10-10
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,11 @@
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 Moq;
11-
using Xunit;
1211
using React.Web.Mvc;
12+
using Xunit;
1313

1414
namespace React.Tests.Mvc
1515
{
@@ -31,7 +31,7 @@ private Mock<IReactEnvironment> ConfigureMockEnvironment()
3131
public void ReactWithInitShouldReturnHtmlAndScript()
3232
{
3333
var component = new Mock<IReactComponent>();
34-
component.Setup(x => x.RenderHtml(false, false)).Returns("HTML");
34+
component.Setup(x => x.RenderHtml(false, false, null)).Returns("HTML");
3535
component.Setup(x => x.RenderJavaScript()).Returns("JS");
3636
var environment = ConfigureMockEnvironment();
3737
environment.Setup(x => x.CreateComponent(
@@ -57,7 +57,7 @@ public void ReactWithInitShouldReturnHtmlAndScript()
5757
public void EngineIsReturnedToPoolAfterRender()
5858
{
5959
var component = new Mock<IReactComponent>();
60-
component.Setup(x => x.RenderHtml(true, true)).Returns("HTML");
60+
component.Setup(x => x.RenderHtml(true, true, null)).Returns("HTML");
6161
var environment = ConfigureMockEnvironment();
6262
environment.Setup(x => x.CreateComponent(
6363
"ComponentName",
@@ -75,15 +75,15 @@ public void EngineIsReturnedToPoolAfterRender()
7575
clientOnly: true,
7676
serverOnly: true
7777
);
78-
component.Verify(x => x.RenderHtml(It.Is<bool>(y => y == true), It.Is<bool>(z => z == true)), Times.Once);
78+
component.Verify(x => x.RenderHtml(It.Is<bool>(y => y == true), It.Is<bool>(z => z == true), null), Times.Once);
7979
environment.Verify(x => x.ReturnEngineToPool(), Times.Once);
8080
}
8181

8282
[Fact]
8383
public void ReactWithClientOnlyTrueShouldCallRenderHtmlWithTrue()
8484
{
8585
var component = new Mock<IReactComponent>();
86-
component.Setup(x => x.RenderHtml(true, true)).Returns("HTML");
86+
component.Setup(x => x.RenderHtml(true, true, null)).Returns("HTML");
8787
var environment = ConfigureMockEnvironment();
8888
environment.Setup(x => x.CreateComponent(
8989
"ComponentName",
@@ -100,13 +100,13 @@ public void ReactWithClientOnlyTrueShouldCallRenderHtmlWithTrue()
100100
clientOnly: true,
101101
serverOnly: true
102102
);
103-
component.Verify(x => x.RenderHtml(It.Is<bool>(y => y == true), It.Is<bool>(z => z == true)), Times.Once);
103+
component.Verify(x => x.RenderHtml(It.Is<bool>(y => y == true), It.Is<bool>(z => z == true), null), Times.Once);
104104
}
105105

106106
[Fact]
107107
public void ReactWithServerOnlyTrueShouldCallRenderHtmlWithTrue() {
108108
var component = new Mock<IReactComponent>();
109-
component.Setup(x => x.RenderHtml(true, true)).Returns("HTML");
109+
component.Setup(x => x.RenderHtml(true, true, null)).Returns("HTML");
110110
var environment = ConfigureMockEnvironment();
111111
environment.Setup(x => x.CreateComponent(
112112
"ComponentName",
@@ -123,7 +123,7 @@ public void ReactWithServerOnlyTrueShouldCallRenderHtmlWithTrue() {
123123
clientOnly: true,
124124
serverOnly: true
125125
);
126-
component.Verify(x => x.RenderHtml(It.Is<bool>(y => y == true), It.Is<bool>(z => z == true)), Times.Once);
126+
component.Verify(x => x.RenderHtml(It.Is<bool>(y => y == true), It.Is<bool>(z => z == true), null), Times.Once);
127127
}
128128
}
129129
}

0 commit comments

Comments
 (0)
Please sign in to comment.