diff --git a/src/React.AspNet/ActionHtmlString.cs b/src/React.AspNet/ActionHtmlString.cs index a2879dde2..99179e9b5 100644 --- a/src/React.AspNet/ActionHtmlString.cs +++ b/src/React.AspNet/ActionHtmlString.cs @@ -11,6 +11,7 @@ using System.IO; #if LEGACYASPNET +using System.Text; using System.Web; #else using System.Text.Encodings.Web; @@ -40,13 +41,26 @@ public ActionHtmlString(Action textWriter) } #if LEGACYASPNET + [ThreadStatic] + private static StringWriter _sharedStringWriter; + /// Returns an HTML-encoded string. /// An HTML-encoded string. public string ToHtmlString() { - var sw = new StringWriter(); - _textWriter(sw); - return sw.ToString(); + var stringWriter = _sharedStringWriter; + if (stringWriter != null) + { + stringWriter.GetStringBuilder().Clear(); + } + else + { + _sharedStringWriter = stringWriter = new StringWriter(new StringBuilder(128)); + } + + _textWriter(stringWriter); + + return stringWriter.ToString(); } #else /// diff --git a/src/React.AspNet/HtmlHelperExtensions.cs b/src/React.AspNet/HtmlHelperExtensions.cs index cd821cd48..acc88f11f 100644 --- a/src/React.AspNet/HtmlHelperExtensions.cs +++ b/src/React.AspNet/HtmlHelperExtensions.cs @@ -81,7 +81,7 @@ public static IHtmlString React( reactComponent.ContainerClass = containerClass; } - writer.Write(reactComponent.RenderHtml(clientOnly, serverOnly, exceptionHandler)); + reactComponent.RenderHtml(writer, clientOnly, serverOnly, exceptionHandler); } finally { @@ -131,9 +131,9 @@ public static IHtmlString ReactWithInit( reactComponent.ContainerClass = containerClass; } - writer.Write(reactComponent.RenderHtml(clientOnly, exceptionHandler: exceptionHandler)); + reactComponent.RenderHtml(writer, clientOnly, exceptionHandler: exceptionHandler); writer.WriteLine(); - WriteScriptTag(writer, bodyWriter => bodyWriter.Write(reactComponent.RenderJavaScript())); + WriteScriptTag(writer, bodyWriter => reactComponent.RenderJavaScript(bodyWriter)); } finally { @@ -153,7 +153,7 @@ public static IHtmlString ReactInitJavaScript(this IHtmlHelper htmlHelper, bool { try { - WriteScriptTag(writer, bodyWriter => bodyWriter.Write(Environment.GetInitJavaScript(clientOnly))); + WriteScriptTag(writer, bodyWriter => Environment.GetInitJavaScript(bodyWriter, clientOnly)); } finally { diff --git a/src/React.Core/IReactComponent.cs b/src/React.Core/IReactComponent.cs index b4a6d4428..45ac47ffd 100644 --- a/src/React.Core/IReactComponent.cs +++ b/src/React.Core/IReactComponent.cs @@ -8,6 +8,7 @@ */ using System; +using System.IO; namespace React { @@ -63,5 +64,24 @@ public interface IReactComponent /// /// JavaScript string RenderJavaScript(); + + /// + /// Renders the HTML for this component. This will execute the component server-side and + /// return the rendered HTML. + /// + /// The to which the content is written + /// 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 + void RenderHtml(TextWriter writer, bool renderContainerOnly = false, bool renderServerOnly = false, Action exceptionHandler = null); + + /// + /// Renders the JavaScript required to initialise this component client-side. This will + /// initialise the React component, which includes attach event handlers to the + /// server-rendered HTML. + /// + /// JavaScript + void RenderJavaScript(TextWriter writer); } } diff --git a/src/React.Core/IReactEnvironment.cs b/src/React.Core/IReactEnvironment.cs index 67991360f..4ffe32630 100644 --- a/src/React.Core/IReactEnvironment.cs +++ b/src/React.Core/IReactEnvironment.cs @@ -8,6 +8,8 @@ */ +using System.IO; + namespace React { /// @@ -114,5 +116,14 @@ public interface IReactEnvironment /// Gets the site-wide configuration. /// IReactSiteConfiguration Configuration { get; } + + /// + /// Renders the JavaScript required to initialise all components client-side. This will + /// attach event handlers to the server-rendered HTML. + /// + /// The to which the content is written + /// True if server-side rendering will be bypassed. Defaults to false. + /// JavaScript for all components + void GetInitJavaScript(TextWriter writer, bool clientOnly = false); } } diff --git a/src/React.Core/ReactComponent.cs b/src/React.Core/ReactComponent.cs index 511b97531..376315779 100644 --- a/src/React.Core/ReactComponent.cs +++ b/src/React.Core/ReactComponent.cs @@ -9,7 +9,9 @@ using System; using System.Collections.Concurrent; +using System.IO; using System.Linq; +using System.Text; using System.Text.RegularExpressions; using JavaScriptEngineSwitcher.Core; using Newtonsoft.Json; @@ -24,6 +26,9 @@ public class ReactComponent : IReactComponent { private static readonly ConcurrentDictionary _componentNameValidCache = new ConcurrentDictionary(StringComparer.Ordinal); + [ThreadStatic] + private static StringWriter _sharedStringWriter; + /// /// Regular expression used to validate JavaScript identifiers. Used to ensure component /// names are valid. @@ -87,8 +92,7 @@ public object Props _props = value; _serializedProps = JsonConvert.SerializeObject( value, - _configuration.JsonSerializerSettings - ); + _configuration.JsonSerializerSettings); } } @@ -119,6 +123,24 @@ public ReactComponent(IReactEnvironment environment, IReactSiteConfiguration con /// 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, Action exceptionHandler = null) + { + using (var writer = new StringWriter()) + { + RenderHtml(writer, renderContainerOnly, renderServerOnly, exceptionHandler); + return writer.ToString(); + } + } + + /// + /// Renders the HTML for this component. This will execute the component server-side and + /// return the rendered HTML. + /// + /// The to which the content is written + /// 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 void RenderHtml(TextWriter writer, bool renderContainerOnly = false, bool renderServerOnly = false, Action exceptionHandler = null) { if (!_configuration.UseServerSideRendering) { @@ -133,16 +155,28 @@ public virtual string RenderHtml(bool renderContainerOnly = false, bool renderSe var html = string.Empty; if (!renderContainerOnly) { + var stringWriter = _sharedStringWriter; + if (stringWriter != null) + { + stringWriter.GetStringBuilder().Clear(); + } + else + { + _sharedStringWriter = stringWriter = new StringWriter(new StringBuilder(_serializedProps.Length + 128)); + } + try { - var reactRenderCommand = renderServerOnly - ? string.Format("ReactDOMServer.renderToStaticMarkup({0})", GetComponentInitialiser()) - : string.Format("ReactDOMServer.renderToString({0})", GetComponentInitialiser()); - html = _environment.Execute(reactRenderCommand); + stringWriter.Write(renderServerOnly ? "ReactDOMServer.renderToStaticMarkup(" : "ReactDOMServer.renderToString("); + WriteComponentInitialiser(stringWriter); + stringWriter.Write(')'); + + html = _environment.Execute(stringWriter.ToString()); if (renderServerOnly) { - return html; + writer.Write(html); + return; } } catch (JsRuntimeException ex) @@ -156,18 +190,23 @@ public virtual string RenderHtml(bool renderContainerOnly = false, bool renderSe } } - string attributes = string.Format("id=\"{0}\"", ContainerId); + writer.Write('<'); + writer.Write(ContainerTag); + writer.Write(" id=\""); + writer.Write(ContainerId); + writer.Write('"'); if (!string.IsNullOrEmpty(ContainerClass)) { - attributes += string.Format(" class=\"{0}\"", ContainerClass); + writer.Write(" class=\""); + writer.Write(ContainerClass); + writer.Write('"'); } - return string.Format( - "<{2} {0}>{1}", - attributes, - html, - ContainerTag - ); + writer.Write('>'); + writer.Write(html); + writer.Write("'); } /// @@ -178,11 +217,27 @@ public virtual string RenderHtml(bool renderContainerOnly = false, bool renderSe /// JavaScript public virtual string RenderJavaScript() { - return string.Format( - "ReactDOM.hydrate({0}, document.getElementById({1}))", - GetComponentInitialiser(), - JsonConvert.SerializeObject(ContainerId, _configuration.JsonSerializerSettings) // SerializeObject accepts null settings - ); + using (var writer = new StringWriter()) + { + RenderJavaScript(writer); + return writer.ToString(); + } + } + + /// + /// Renders the JavaScript required to initialise this component client-side. This will + /// initialise the React component, which includes attach event handlers to the + /// server-rendered HTML. + /// + /// The to which the content is written + /// JavaScript + public virtual void RenderJavaScript(TextWriter writer) + { + writer.Write("ReactDOM.hydrate("); + WriteComponentInitialiser(writer); + writer.Write(", document.getElementById(\""); + writer.Write(ContainerId); + writer.Write("\"))"); } /// @@ -208,14 +263,14 @@ protected virtual void EnsureComponentExists() /// /// Gets the JavaScript code to initialise the component /// - /// JavaScript for component initialisation - protected virtual string GetComponentInitialiser() + /// The to which the content is written + protected virtual void WriteComponentInitialiser(TextWriter writer) { - return string.Format( - "React.createElement({0}, {1})", - ComponentName, - _serializedProps - ); + writer.Write("React.createElement("); + writer.Write(ComponentName); + writer.Write(", "); + writer.Write(_serializedProps); + writer.Write(')'); } /// diff --git a/src/React.Core/ReactEnvironment.cs b/src/React.Core/ReactEnvironment.cs index edecc5e9d..1f9ebec9f 100644 --- a/src/React.Core/ReactEnvironment.cs +++ b/src/React.Core/ReactEnvironment.cs @@ -10,6 +10,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.IO; using System.Reflection; using System.Text; using System.Threading; @@ -335,25 +336,37 @@ public virtual IReactComponent CreateComponent(IReactComponent component, bool c /// JavaScript for all components public virtual string GetInitJavaScript(bool clientOnly = false) { - var fullScript = new StringBuilder(); - + using (var writer = new StringWriter()) + { + GetInitJavaScript(writer, clientOnly); + return writer.ToString(); + } + } + + /// + /// Renders the JavaScript required to initialise all components client-side. This will + /// attach event handlers to the server-rendered HTML. + /// + /// The to which the content is written + /// True if server-side rendering will be bypassed. Defaults to false. + /// JavaScript for all components + public virtual void GetInitJavaScript(TextWriter writer, bool clientOnly = false) + { // Propagate any server-side console.log calls to corresponding client-side calls. if (!clientOnly) { var consoleCalls = Execute("console.getCalls()"); - fullScript.Append(consoleCalls); + writer.Write(consoleCalls); } - + foreach (var component in _components) { if (!component.ServerOnly) { - fullScript.Append(component.RenderJavaScript()); - fullScript.AppendLine(";"); + component.RenderJavaScript(writer); + writer.WriteLine(';'); } } - - return fullScript.ToString(); } /// diff --git a/src/React.Router/ReactRouterComponent.cs b/src/React.Router/ReactRouterComponent.cs index 993ac2953..631b2cb50 100644 --- a/src/React.Router/ReactRouterComponent.cs +++ b/src/React.Router/ReactRouterComponent.cs @@ -7,6 +7,7 @@ * of patent rights can be found in the PATENTS file in the same directory. */ +using System.IO; using Newtonsoft.Json; namespace React.Router @@ -67,15 +68,15 @@ public virtual ExecutionResult RenderRouterWithContext(bool renderContainerOnly /// Gets the JavaScript code to initialise the component /// /// JavaScript for component initialisation - protected override string GetComponentInitialiser() + protected override void WriteComponentInitialiser(TextWriter writer) { - return string.Format( - @"React.createElement - ({0}, Object.assign({1}, {{ location: '{2}', context: context }}))", - ComponentName, - _serializedProps, - _path - ); + writer.Write("React.createElement("); + writer.Write(ComponentName); + writer.Write(", Object.assign("); + writer.Write(_serializedProps); + writer.Write(", { location: '"); + writer.Write(_path); + writer.Write("', context: context }))"); } /// @@ -86,13 +87,13 @@ protected override string GetComponentInitialiser() /// Client side React Router does not need context nor explicit path parameter. /// /// JavaScript - public override string RenderJavaScript() + public override void RenderJavaScript(TextWriter writer) { - return string.Format( - "ReactDOM.hydrate({0}, document.getElementById({1}))", - base.GetComponentInitialiser(), - JsonConvert.SerializeObject(ContainerId, _configuration.JsonSerializerSettings) // SerializeObject accepts null settings - ); + writer.Write("ReactDOM.hydrate("); + base.WriteComponentInitialiser(writer); + writer.Write(", document.getElementById(\""); + writer.Write(ContainerId); + writer.Write("\"))"); } } } diff --git a/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs b/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs index f2a41735f..53c6b594e 100644 --- a/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs +++ b/tests/React.Tests/Mvc/HtmlHelperExtensionsTests.cs @@ -8,6 +8,7 @@ */ using System; +using System.IO; using System.Security.Cryptography; using Moq; using React.Web.Mvc; @@ -34,8 +35,12 @@ private Mock ConfigureMockEnvironment(IReactSiteConfiguration public void ReactWithInitShouldReturnHtmlAndScript() { var component = new Mock(); - component.Setup(x => x.RenderHtml(false, false, null)).Returns("HTML"); - component.Setup(x => x.RenderJavaScript()).Returns("JS"); + + component.Setup(x => x.RenderHtml(It.IsAny(), false, false, null)) + .Callback((TextWriter writer, bool renderContainerOnly, bool renderServerOnly, Action exceptionHandler) => writer.Write("HTML")); + + component.Setup(x => x.RenderJavaScript(It.IsAny())).Callback((TextWriter writer) => writer.Write("JS")); + var environment = ConfigureMockEnvironment(); environment.Setup(x => x.CreateComponent( "ComponentName", @@ -51,6 +56,7 @@ public void ReactWithInitShouldReturnHtmlAndScript() props: new { }, htmlTag: "span" ).ToHtmlString(); + Assert.Equal( "HTML" + System.Environment.NewLine + "", result.ToString() @@ -69,8 +75,10 @@ public void ScriptNonceIsReturned() } var component = new Mock(); - component.Setup(x => x.RenderHtml(false, false, null)).Returns("HTML"); - component.Setup(x => x.RenderJavaScript()).Returns("JS"); + component.Setup(x => x.RenderHtml(It.IsAny(), false, false, null)) + .Callback((TextWriter writer, bool renderContainerOnly, bool renderServerOnly, Action exceptionHandler) => writer.Write("HTML")).Verifiable(); + + component.Setup(x => x.RenderJavaScript(It.IsAny())).Callback((TextWriter writer) => writer.Write("JS")).Verifiable(); var config = new Mock(); @@ -92,6 +100,7 @@ public void ScriptNonceIsReturned() props: new { }, htmlTag: "span" ).ToHtmlString(); + Assert.Equal( "HTML" + System.Environment.NewLine + "", result.ToString() @@ -106,6 +115,7 @@ public void ScriptNonceIsReturned() props: new { }, htmlTag: "span" ).ToHtmlString(); + Assert.Equal( "HTML" + System.Environment.NewLine + "", result.ToString() @@ -116,7 +126,9 @@ public void ScriptNonceIsReturned() public void EngineIsReturnedToPoolAfterRender() { var component = new Mock(); - component.Setup(x => x.RenderHtml(true, true, null)).Returns("HTML"); + component.Setup(x => x.RenderHtml(It.IsAny(), false, false, null)) + .Callback((TextWriter writer, bool renderContainerOnly, bool renderServerOnly, Action exceptionHandler) => writer.Write("HTML")).Verifiable(); + var environment = ConfigureMockEnvironment(); environment.Setup(x => x.CreateComponent( "ComponentName", @@ -135,7 +147,8 @@ public void EngineIsReturnedToPoolAfterRender() clientOnly: true, serverOnly: false ).ToHtmlString(); - component.Verify(x => x.RenderHtml(It.Is(y => y == true), It.Is(z => z == false), null), Times.Once); + + component.Verify(x => x.RenderHtml(It.IsAny(), It.Is(y => y == true), It.Is(z => z == false), null), Times.Once); environment.Verify(x => x.ReturnEngineToPool(), Times.Once); } @@ -143,7 +156,9 @@ public void EngineIsReturnedToPoolAfterRender() public void ReactWithClientOnlyTrueShouldCallRenderHtmlWithTrue() { var component = new Mock(); - component.Setup(x => x.RenderHtml(true, true, null)).Returns("HTML"); + component.Setup(x => x.RenderHtml(It.IsAny(), false, false, null)) + .Callback((TextWriter writer, bool renderContainerOnly, bool renderServerOnly, Action exceptionHandler) => writer.Write("HTML")).Verifiable(); + var environment = ConfigureMockEnvironment(); environment.Setup(x => x.CreateComponent( "ComponentName", @@ -161,14 +176,17 @@ public void ReactWithClientOnlyTrueShouldCallRenderHtmlWithTrue() clientOnly: true, serverOnly: false ).ToHtmlString(); - component.Verify(x => x.RenderHtml(It.Is(y => y == true), It.Is(z => z == false), null), Times.Once); + + component.Verify(x => x.RenderHtml(It.IsAny(), It.Is(y => y == true), It.Is(z => z == false), null), Times.Once); } [Fact] public void ReactWithServerOnlyTrueShouldCallRenderHtmlWithTrue() { var component = new Mock(); - component.Setup(x => x.RenderHtml(false, true, null)).Returns("HTML"); + component.Setup(x => x.RenderHtml(It.IsAny(), false, false, null)) + .Callback((TextWriter writer, bool renderContainerOnly, bool renderServerOnly, Action exceptionHandler) => writer.Write("HTML")).Verifiable(); + var environment = ConfigureMockEnvironment(); environment.Setup(x => x.CreateComponent( "ComponentName", @@ -186,7 +204,8 @@ public void ReactWithServerOnlyTrueShouldCallRenderHtmlWithTrue() clientOnly: false, serverOnly: true ).ToHtmlString(); - component.Verify(x => x.RenderHtml(It.Is(y => y == false), It.Is(z => z == true), null), Times.Once); + + component.Verify(x => x.RenderHtml(It.IsAny(), It.Is(y => y == false), It.Is(z => z == true), null), Times.Once); } } }