How to migrate usage of StringBuilder+TagBuilder for HTML rendering?

523 Views Asked by At

I am migrating projects from .NET Framework to .NET core. This question is about ASP MVC projects. I have a lot of HtmlHelper extension methods and many of these use the TagBuilder class to generate HTML elements while generating content into a StringBuilder.

public static HtmlString DisplayTiles(this IHtmlHelper html, TileDashboardModel groups, bool displayNotEnabled = false, bool displayUnauthorized = false)
{
    if (groups == null) throw new ArgumentNullException(nameof(groups));

    var content = new StringBuilder();
    var div = new TagBuilder("div");
    content.AppendLine(div.ToString(TagRenderMode.StartTag));                     // does not build
    foreach (var group in groups.Groups)
    {
        DisplayTiles(html, content, group, displayNotEnabled, displayUnauthorized);
    }

    content.AppendLine(div.ToString(TagRenderMode.EndTag));                       // does not build

    ////return new MvcHtmlString(sb.ToString());                                  // does not build
    return new HtmlString(content.ToString());
}

In .NET Core the TagBuilder.ToString() method does not accept any argument. And rendering method seem to force you to use a StringWriter.

What ways would you change the code to make it work?

Consider: ease of change, memory consumption, extension method interference, code readability.


Related but different: Migrating TagBuilder core methods from ASP.NET MVC 5 to ASP.NET Core, How to display content of StringBuilder as HTML?

2

There are 2 best solutions below

2
SandRock On

The ways I found are these, from the "best" to the worst. full code here

Initial code

This only works with .NET Framework.

    /// <summary>
    /// The old way.
    /// </summary>
    [Fact]
    public void Test0()
    {
        var contents = new StringBuilder();
        var tag = new TagBuilder("div");
        tag.SetInnerText("hello");                              // method does not exist
    
        contents.Append(tag.ToString(TagRenderMode.StartTag));  // missing method overload
        Assert.Equal("<div>", contents.ToString());

        contents.Append(tag.InnerHtml);
        Assert.Equal("<div>hello", contents.ToString());

        contents.Append(tag.ToString(TagRenderMode.EndTag));    // missing method overload
        Assert.Equal("<div>hello</div>", contents.ToString());

        var result = MvcHtmlString.Create(contents.ToString()); // class does not exist
        Assert.Equal("<div>hello</div>", result.ToString());
    }

Minimal changes

This method focuses on minimal changes. You only need to change one type and declare a 3 extension methods.

    /// <summary>
    /// Minimal code changes: extension methods.
    /// </summary>
    [Fact]
    public void Test4()
    {
        var contents = new StringWriter();                     // change this type
        var tag = new TagBuilder("div");
        tag.SetInnerText("hello");                             // 1 extension method

        contents.Append(tag.ToString(TagRenderMode.StartTag)); // 2 extension methods
        Assert.Equal("<div>", contents.ToString());

        contents.Append(tag.InnerHtml);                        // 1 extension method
        Assert.Equal("<div>hello", contents.ToString());

        contents.Append(tag.ToString(TagRenderMode.EndTag));   // 2 extension methods
        Assert.Equal("<div>hello</div>", contents.ToString());
    }

    public static void SetInnerText(this TagBuilder tag, string value)
    {
        tag.InnerHtml.Append(value);
    }

    public static void Append(this StringWriter writer, IHtmlContent html)
    {
        html.WriteTo(writer, HtmlEncoder.Default);
    }

    public static IHtmlContent ToString(this TagBuilder tag, TagRenderMode mode)
    {
        if (mode == TagRenderMode.StartTag)
        {
            return tag.RenderStartTag();
        }
        else if (mode == TagRenderMode.EndTag)
        {
            return tag.RenderEndTag();
        }
        else
        {
            throw new ArgumentException();
        }
    }

Minimal call stack

Here a focus on not using extra methods; code changes a lot.


    /// <summary>
    /// Use StringWriter instead of StringBuilder, no ext. methods.
    /// </summary>
    [Fact]
    public void Test3()
    {
        var contents = new StringWriter();                           // changed
        var tag = new TagBuilder("div");
        tag.InnerHtml.Append("hello");

        tag.RenderStartTag().WriteTo(contents, HtmlEncoder.Default); // changed
        Assert.Equal("<div>", contents.ToString());

        tag.RenderBody()?.WriteTo(contents, HtmlEncoder.Default);    // changed
        Assert.Equal("<div>hello", contents.ToString());

        tag.RenderEndTag().WriteTo(contents, HtmlEncoder.Default);   // changed
        Assert.Equal("<div>hello</div>", contents.ToString());
    }

The memory hogger

There is a known extension method that will allocate too much memory. Don't do this one.

    /// <summary>
    /// Use Render methods and bad extension methods.
    /// </summary>
    /// <remarks>So many StringWriters</remarks>
    [Fact]
    public void Test2()
    {
        var contents = new StringBuilder();
        var tag = new TagBuilder("div");
        tag.InnerHtml.Append("hello");
        
        contents.AppendEx(tag.RenderStartTag());
        Assert.Equal("<div>", contents.ToString());

        contents.AppendEx(tag.RenderBody());
        Assert.Equal("<div>hello", contents.ToString());

        contents.AppendEx(tag.RenderEndTag());
        Assert.Equal("<div>hello</div>", contents.ToString());
    }

    public static void AppendEx(this StringBuilder builder, IHtmlContent? content)
    {
        if (content != null)
        {
            using (var writer = new StringWriter(builder))
            {
                content.WriteTo(writer, HtmlEncoder.Default);
            }
        }
    }

Here is a variant using TagRenderMode.

    /// <summary>
    /// Set RenderMode and use extension methods.
    /// </summary>
    /// <remarks>So many StringWriters</remarks>
    [Fact]
    public void Test1()
    {
        var contents = new StringBuilder();
        var tag = new TagBuilder("div");
        tag.InnerHtml.Append("hello");
        
        tag.TagRenderMode = TagRenderMode.StartTag;
        contents.AppendEx(tag);
        Assert.Equal("<div>", contents.ToString());

        contents.AppendEx(tag.InnerHtml);
        Assert.Equal("<div>hello", contents.ToString());

        tag.TagRenderMode = TagRenderMode.EndTag;
        contents.AppendEx(tag);
        Assert.Equal("<div>hello</div>", contents.ToString());
    }

    public static void AppendEx(this StringBuilder contents, TagBuilder tag)
    {
        // why do we need to do that? my StringBuilder is no good enough?
        using (var writer = new StringWriter(contents))
        {
            tag.WriteTo(writer, HtmlEncoder.Default);
        }
    }
0
SandRock On

The new TagBuilder (in Microsoft.AspNetCore.Mvc.Rendering) behaves very differently from the old TagBuilder (in System.Web.Mvc). This makes code changes very hard; even harder if you do not have unit tests; you may get "Microsoft.AspNetCore.Mvc.Rendering.TagBuilder" printed everywhere on your pages.

The workaround

To avoid having a hard time migrating things for real, the fastest action is:

  • copy the old TagBuilder.cs in your project
  • rename it TagBuilderOld (to avoid type name conflicts)
  • copy the old TagRenderMode
  • rename it TagRenderModeOld (to avoid type name conflicts)
  • import it in your helpers:
    using TagBuilder = System.Web.Mvc.TagBuilderOld;
    using TagRenderMode = System.Web.Mvc.TagRenderModeOld;
  • you can also import the new one if you wish to become mad:
    using TagBuilderNew = Microsoft.AspNetCore.Mvc.Rendering.TagBuilder;
    using TagRenderModeNew = Microsoft.AspNetCore.Mvc.Rendering.TagRenderMode;

This way it works well. No "Microsoft.AspNetCore.Mvc.Rendering.TagBuilder" everywhere on your pages.

Warning: this is a workaround! You will not get feature and security updates if you do that. See the other answers for a more serious way to solve the issue.