Skip to content

Commit 9d14b8e

Browse files
Fix exception handling during render in .NET Core (#793)
If an exception is thrown outside of the render call in Razor views (async invocation), the app could crash. This does not appear to be an issue in ASP.NET. Unfortunately this means extra string allocations to hold the render result, but the rest of the StringBuilder logic used internally is not a problem.
1 parent 9872ac6 commit 9d14b8e

File tree

5 files changed

+138
-121
lines changed

5 files changed

+138
-121
lines changed

src/React.AspNet/ActionHtmlString.cs

-76
This file was deleted.

src/React.AspNet/HtmlHelperExtensions.cs

+62-43
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,14 @@
77

88
using System;
99
using System.IO;
10+
using System.Text;
1011

1112
#if LEGACYASPNET
1213
using System.Web;
1314
using IHtmlHelper = System.Web.Mvc.HtmlHelper;
1415
#else
1516
using Microsoft.AspNetCore.Mvc.Rendering;
17+
using Microsoft.AspNetCore.Html;
1618
using IHtmlString = Microsoft.AspNetCore.Html.IHtmlContent;
1719
#endif
1820

@@ -27,6 +29,9 @@ namespace React.AspNet
2729
/// </summary>
2830
public static class HtmlHelperExtensions
2931
{
32+
[ThreadStatic]
33+
private static StringWriter _sharedStringWriter;
34+
3035
/// <summary>
3136
/// Gets the React environment
3237
/// </summary>
@@ -66,28 +71,25 @@ public static IHtmlString React<T>(
6671
IRenderFunctions renderFunctions = null
6772
)
6873
{
69-
return new ActionHtmlString(writer =>
74+
try
7075
{
71-
try
76+
var reactComponent = Environment.CreateComponent(componentName, props, containerId, clientOnly, serverOnly);
77+
if (!string.IsNullOrEmpty(htmlTag))
7278
{
73-
var reactComponent = Environment.CreateComponent(componentName, props, containerId, clientOnly, serverOnly);
74-
if (!string.IsNullOrEmpty(htmlTag))
75-
{
76-
reactComponent.ContainerTag = htmlTag;
77-
}
78-
79-
if (!string.IsNullOrEmpty(containerClass))
80-
{
81-
reactComponent.ContainerClass = containerClass;
82-
}
83-
84-
reactComponent.RenderHtml(writer, clientOnly, serverOnly, exceptionHandler, renderFunctions);
79+
reactComponent.ContainerTag = htmlTag;
8580
}
86-
finally
81+
82+
if (!string.IsNullOrEmpty(containerClass))
8783
{
88-
Environment.ReturnEngineToPool();
84+
reactComponent.ContainerClass = containerClass;
8985
}
90-
});
86+
87+
return RenderToString(writer => reactComponent.RenderHtml(writer, clientOnly, serverOnly, exceptionHandler, renderFunctions));
88+
}
89+
finally
90+
{
91+
Environment.ReturnEngineToPool();
92+
}
9193
}
9294

9395
/// <summary>
@@ -116,31 +118,32 @@ public static IHtmlString ReactWithInit<T>(
116118
Action<Exception, string, string> exceptionHandler = null
117119
)
118120
{
119-
return new ActionHtmlString(writer =>
121+
try
120122
{
121-
try
123+
var reactComponent = Environment.CreateComponent(componentName, props, containerId, clientOnly);
124+
if (!string.IsNullOrEmpty(htmlTag))
122125
{
123-
var reactComponent = Environment.CreateComponent(componentName, props, containerId, clientOnly);
124-
if (!string.IsNullOrEmpty(htmlTag))
125-
{
126-
reactComponent.ContainerTag = htmlTag;
127-
}
126+
reactComponent.ContainerTag = htmlTag;
127+
}
128128

129-
if (!string.IsNullOrEmpty(containerClass))
130-
{
131-
reactComponent.ContainerClass = containerClass;
132-
}
129+
if (!string.IsNullOrEmpty(containerClass))
130+
{
131+
reactComponent.ContainerClass = containerClass;
132+
}
133133

134+
return RenderToString(writer =>
135+
{
134136
reactComponent.RenderHtml(writer, clientOnly, exceptionHandler: exceptionHandler);
135137
writer.WriteLine();
136138
WriteScriptTag(writer, bodyWriter => reactComponent.RenderJavaScript(bodyWriter));
137-
}
138-
finally
139-
{
140-
Environment.ReturnEngineToPool();
141-
}
142-
});
143-
}
139+
});
140+
141+
}
142+
finally
143+
{
144+
Environment.ReturnEngineToPool();
145+
}
146+
}
144147

145148
/// <summary>
146149
/// Renders the JavaScript required to initialise all components client-side. This will
@@ -149,17 +152,33 @@ public static IHtmlString ReactWithInit<T>(
149152
/// <returns>JavaScript for all components</returns>
150153
public static IHtmlString ReactInitJavaScript(this IHtmlHelper htmlHelper, bool clientOnly = false)
151154
{
152-
return new ActionHtmlString(writer =>
155+
try
153156
{
154-
try
157+
return RenderToString(writer =>
155158
{
156159
WriteScriptTag(writer, bodyWriter => Environment.GetInitJavaScript(bodyWriter, clientOnly));
157-
}
158-
finally
159-
{
160-
Environment.ReturnEngineToPool();
161-
}
162-
});
160+
});
161+
}
162+
finally
163+
{
164+
Environment.ReturnEngineToPool();
165+
}
166+
}
167+
168+
private static IHtmlString RenderToString(Action<StringWriter> withWriter)
169+
{
170+
var stringWriter = _sharedStringWriter;
171+
if (stringWriter != null)
172+
{
173+
stringWriter.GetStringBuilder().Clear();
174+
}
175+
else
176+
{
177+
_sharedStringWriter = stringWriter = new StringWriter(new StringBuilder(128));
178+
}
179+
180+
withWriter(stringWriter);
181+
return new HtmlString(stringWriter.ToString());
163182
}
164183

165184
private static void WriteScriptTag(TextWriter writer, Action<TextWriter> bodyWriter)

src/React.Web.Mvc4/React.Web.Mvc4.csproj

+1-1
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
</PropertyGroup>
2222

2323
<ItemGroup>
24-
<Compile Include="..\SharedAssemblyInfo.cs;..\React.AspNet\HtmlHelperExtensions.cs;..\React.AspNet\ActionHtmlString.cs" />
24+
<Compile Include="..\SharedAssemblyInfo.cs;..\React.AspNet\HtmlHelperExtensions.cs" />
2525
<Compile Include="..\SharedAssemblyVersionInfo.cs" />
2626
<Content Include="Content\**\*">
2727
<Pack>true</Pack>

tests/React.Tests/Core/ReactComponentTest.cs

+31-1
Original file line numberDiff line numberDiff line change
@@ -407,7 +407,37 @@ public void ChainedRenderFunctionsCalled()
407407
Assert.Equal("postrender-result", firstInstance.PostRenderResult);
408408
Assert.Equal("postrender-result", secondInstance.PostRenderResult);
409409
}
410-
410+
411+
[Fact]
412+
public void RenderFunctionsCalledServerOnly()
413+
{
414+
var environment = new Mock<IReactEnvironment>();
415+
environment.Setup(x => x.Execute<bool>("typeof Foo !== 'undefined'")).Returns(true);
416+
environment.Setup(x => x.Execute<string>(@"outerWrap(ReactDOMServer.renderToStaticMarkup(wrap(React.createElement(Foo, {""hello"":""World""}))))"))
417+
.Returns("[HTML]");
418+
419+
environment.Setup(x => x.Execute<string>(@"prerender();"))
420+
.Returns("prerender-result");
421+
422+
environment.Setup(x => x.Execute<string>(@"postrender();"))
423+
.Returns("postrender-result");
424+
425+
var config = CreateDefaultConfigMock();
426+
config.Setup(x => x.UseServerSideRendering).Returns(true);
427+
var reactIdGenerator = new Mock<IReactIdGenerator>();
428+
429+
var component = new ReactComponent(environment.Object, config.Object, reactIdGenerator.Object, "Foo", "container")
430+
{
431+
Props = new { hello = "World" }
432+
};
433+
var renderFunctions = new TestRenderFunctions();
434+
var result = component.RenderHtml(renderFunctions: renderFunctions, renderServerOnly: true);
435+
436+
Assert.Equal(@"[HTML]", result);
437+
Assert.Equal(@"prerender-result", renderFunctions.PreRenderResult);
438+
Assert.Equal(@"postrender-result", renderFunctions.PostRenderResult);
439+
}
440+
411441
private static Mock<IReactSiteConfiguration> CreateDefaultConfigMock()
412442
{
413443
var configMock = new Mock<IReactSiteConfiguration>();

tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs

+44
Original file line numberDiff line numberDiff line change
@@ -206,6 +206,50 @@ public void ReactWithServerOnlyTrueShouldCallRenderHtmlWithTrue()
206206

207207
component.Verify(x => x.RenderHtml(It.IsAny<TextWriter>(), It.Is<bool>(y => y == false), It.Is<bool>(z => z == true), null, null), Times.Once);
208208
}
209+
210+
[Fact]
211+
public void RenderFunctionsCalledNonLazily()
212+
{
213+
var component = new Mock<IReactComponent>();
214+
var fakeRenderFunctions = new Mock<IRenderFunctions>();
215+
fakeRenderFunctions.Setup(x => x.PreRender(It.IsAny<Func<string, string>>())).Verifiable();
216+
fakeRenderFunctions.Setup(x => x.PostRender(It.IsAny<Func<string, string>>())).Verifiable();
217+
fakeRenderFunctions.Setup(x => x.TransformRenderedHtml(It.IsAny<string>())).Returns("HTML");
218+
219+
component.Setup(x => x.RenderHtml(It.IsAny<TextWriter>(), It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<Action<Exception, string, string>>(), It.IsAny<IRenderFunctions>()))
220+
.Callback((TextWriter writer, bool renderContainerOnly, bool renderServerOnly, Action<Exception, string, string> exceptionHandler, IRenderFunctions renderFunctions) =>
221+
{
222+
renderFunctions.PreRender(_ => "one");
223+
writer.Write(renderFunctions.TransformRenderedHtml("HTML"));
224+
renderFunctions.PostRender(_ => "two");
225+
}).Verifiable();
226+
227+
var environment = ConfigureMockEnvironment();
228+
environment.Setup(x => x.CreateComponent(
229+
"ComponentName",
230+
new { },
231+
null,
232+
false,
233+
true
234+
)).Returns(component.Object);
235+
236+
var result = HtmlHelperExtensions.React(
237+
htmlHelper: null,
238+
componentName: "ComponentName",
239+
props: new { },
240+
htmlTag: "span",
241+
clientOnly: false,
242+
serverOnly: true,
243+
renderFunctions: fakeRenderFunctions.Object
244+
);
245+
246+
// JS calls must happen right away so thrown exceptions do not crash the app.
247+
component.Verify(x => x.RenderHtml(It.IsAny<TextWriter>(), It.Is<bool>(y => y == false), It.Is<bool>(z => z == true), It.IsAny<Action<Exception, string, string>>(), It.IsAny<IRenderFunctions>()), Times.Once);
248+
fakeRenderFunctions.Verify(x => x.PreRender(It.IsAny<Func<string, string>>()), Times.Once);
249+
fakeRenderFunctions.Verify(x => x.PostRender(It.IsAny<Func<string, string>>()), Times.Once);
250+
251+
Assert.Equal("HTML", result.ToHtmlString());
252+
}
209253
}
210254
}
211255
#endif

0 commit comments

Comments
 (0)