blazor-webassemblyquillasp.net-core-8

Blazor 8 Global Webassembly Component inheriting from InputBase<T> wrapping quill.js throws exception


In a Blazor 8 Webassembly app with Interactivity Level set to Global, I'm trying to create a custom input component (in other words, it inherits from InputBase<string>) that wraps quill.js.

It works fine in spite of throwing this exception:

crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
      Unhandled exception rendering component: TypeError: Cannot read properties of null (reading 'removeChild')

I've tried two approaches. In the first approach I used Blazor's @onfocusout event and then called into javascript to get the html.

InputRichTextEditorDotnet.razor code:

@inherits InputBase<string>
@inject IJSRuntime JSRuntime

<div class="mb-3">
    <label class="form-label fw-bold">@DisplayName</label>
    <div @ref="editor" @onfocusout="onBlur" tabindex="-1">
        @((MarkupString)CurrentValue)
    </div>
</div>

@code {
    ElementReference editor;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if(firstRender)
        {
            await JSRuntime.InvokeVoidAsync("initQuill", editor);
        }
        await base.OnAfterRenderAsync(firstRender);
    }

    async Task onBlur()
    {
        var quillResult = await JSRuntime.InvokeAsync<string>("getHtml");

        //gets the html just fine
        Console.WriteLine(quillResult);

        //this throws the exception
        CurrentValue = quillResult;
    }

    protected override bool TryParseValueFromString(string value, out string result, out string validationErrorMessage)
    {
        result = value;
        validationErrorMessage = null;
        return true;
    }
}

Javascript:

var quill;

function initQuill(el) {
    quill = new Quill(el, {
        theme: 'snow'
    });
}

// InputRichTextEditorDotnet notices the div losing focus and calls this method
function getHtml() {
    return quill.getSemanticHTML();
}

In the second approach I let javascript handle the blur event and then call into dotnet.

InputRichTextEditorQuill.razor:

@inherits InputBase<string>
@inject IJSRuntime JSRuntime

<div class="mb-3">
    <label class="form-label fw-bold">@DisplayName</label>
    <div @ref="editor" contenteditable>
        @((MarkupString)CurrentValue)
    </div>
</div>

@code {
    ElementReference editor;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            var lDotNetReference = DotNetObjectReference.Create(this);
            await JSRuntime.InvokeVoidAsync("setDotnetReference", lDotNetReference);

            await JSRuntime.InvokeVoidAsync("initQuill", editor);
        }
        await base.OnAfterRenderAsync(firstRender);
    }

    [JSInvokable]
    public void JsCalled(string html)
    {
        //gets the html just fine
        Console.WriteLine("HTML FROM Razor JsCalled(): " + html);

        //this throws the exception
        CurrentValue = html;
    }

    protected override bool TryParseValueFromString(string value, out string result, out string validationErrorMessage)
    {
        result = value;
        validationErrorMessage = null;
        return true;
    }
}

Javascript:

var quill;

// calling into the dotnet code
var GLOBAL = {};
GLOBAL.DotNetReference = null;

function setDotnetReference(pDotNetReference) {
    GLOBAL.DotNetReference = pDotNetReference;
}

function initQuill(el) {
    quill = new Quill(el, {
        theme: 'snow'
    });

    el.onblur = function () {
        editorBlurred();
    }
}


//bubble up the blur event to dotnet
function editorBlurred() {
    const html = quill.getSemanticHTML();
    console.log('HTML FROM JS editorBlurred(): ' + html);
    GLOBAL.DotNetReference.invokeMethodAsync('JsCalled', html);
}

And the usage on Home.razor:

@page "/"
@using QuillWrapperForSO.Client.Components

<PageTitle>Home</PageTitle>

@if (person != null)
{
    <EditForm Model="person" OnValidSubmit="onValidSubmit">

        <InputRichTextEditorDotnet DisplayName="Fubar" @bind-Value="person.Fubar" />

        <p>
            Fubar: @person.Fubar
        </p>

        <InputRichTextEditorQuill DisplayName="Baz" @bind-Value="person.Baz" />
        <p>
            Baz: @person.Baz
        </p>

        <input type="submit" />
    </EditForm>
}

@code {

    public class Person
    {
        public string Fubar { get; set; }
        public string Baz { get; set; }
    }

    Person person = new() { Fubar = "<h1>Hello Dolly!</h1>", Baz = "<p>How ye be?</p>" };

    void onValidSubmit()
    {

    }
}

Again, the exception thrown is

crit: Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
      Unhandled exception rendering component: TypeError: Cannot read properties of null (reading 'removeChild')

I've created a repository with the code here

The project is the standard template you get when you create a Blazor Webassembly app with the Interactivity Location set to Global, save two changes:

in App.razor, I changed

private IComponentRenderMode RenderModeForPage => HttpContext.Request.Path.StartsWithSegments("/Account")
        ? null
        : InteractiveWebAssembly;

to

private IComponentRenderMode RenderModeForPage => HttpContext.Request.Path.StartsWithSegments("/Account")
        ? null
        : new InteractiveWebAssemblyRenderMode(prerender: false);

I also deleted <nullable>enable</nullable> in both projects.

Apparently I'm working above my pay grade here. I'm guessing Blazor is not too happy about the DOM being mutated without its awareness.

The code is simplified for brevity, bottom line is I'm looking for an InputComponent that gets picked up in an EditForm component with validation and binding and whatnot which is simple, easy-peasy for the end user.

Any help appreciated.


Solution

  • Super hacky, but the binding works fine, so I ended up "solving" the issue by calling into javascript to hide the #blazor-error-ui element:

    async Task editorLostFocus()
    {
        var quillResult = await js_.InvokeAsync<string>("getHtml");
    
        CurrentValueAsString = quillResult;
    
        //added this line
        await js_.InvokeVoidAsync("hideBlazorErrorUi");
    }
    

    and in javascript:

    function hideBlazorErrorUi() {
        var el = document.querySelector('#blazor-error-ui');
        if (el) {
            el.style.display = 'none';
        }
    }
    

    Total hack, but it works.