I ran into an issue today with a component that wraps a JS library that can affect the Blazor component's bound value. Updating the value of the Mask
parameter would result in the JavaScript code running multiple times.
[Parameter]
public string Value { get; set; }
[Parameter]
public EventCallback<string> ValueChanged { get; set; }
[Parameter]
public Mask Mask { get; set; }
private Mask _mask;
private bool _maskNeedsUpdate;
protected override void OnParametersSet()
{
// ... Handle other parameters.
if (_mask != Mask)
{
_mask = Mask;
_maskNeedsUpdate = true;
}
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// ... initialize JS mask on first render.
if (_maskNeedsUpdate)
{
var valueAfterUpdate = await Js.InvokeAsync<string>("updateMask", _mask);
if (valueAfterUpdate != Value)
{
await ValueChanged.InvokeAsync(valueAfterUpdate);
}
_maskNeedsUpdate = false; // <-- This causes problems.
}
}
It's almost as if that ValueChanged.InvokeAsync()
call triggers a re-render in parallel, which causes a second JS interop call to be queued since the flag is still true
. But I doubt it since it's literally set to false on the very next line (meanwhile, all EventCallback.InvokeAsync()
seems to do is essentially call StateHasChanged()
in the parent component and call its callback).
Changing the code to set the flag before calling JS seems to have fixed the issue.
protected override async Task OnAfterRenderAsync(bool firstRender)
{
// ... initialize JS mask on first render.
if (_maskNeedsUpdate)
{
_maskNeedsUpdate = false; // <-- Toggle flag before awaiting JS.
var valueAfterUpdate = await Js.InvokeAsync<string>("updateMask", _mask);
if (valueAfterUpdate != Value)
{
await ValueChanged.InvokeAsync(valueAfterUpdate);
}
}
}
This fix kind of confuses me, though, because according to the docs:
Blazor uses a synchronization context (SynchronizationContext) to enforce a single logical thread of execution. A component's lifecycle methods and event callbacks raised by Blazor are executed on the synchronization context.
Blazor's server-side synchronization context attempts to emulate a single-threaded environment so that it closely matches the WebAssembly model in the browser, which is single threaded. This emulation is scoped only to an individual circuit, meaning two different circuits can run in parallel. At any given point in time within a circuit, work is performed on exactly one thread, which yields the impression of a single logical thread. No two operations execute concurrently within the same circuit.
My understanding is that the single-thread emulation shouldn't allow my Blazor components to "re-render in parallel" while my JavaScript is still running (and, looking at the Blazor source code, OnAfterRenderAsync()
is eventually awaited). But, then why does moving the flag up before the JS interop call fix my issue? Even if the parent re-renders, wouldn't the single-thread emulation prevent it from re-rendering in parallel?
I'd like to know if I'm supposed to watch out for race conditions when designing components that make calls to the JavaScript interop.
The above example was a simple enough fix, but I have other components that wrap JS libraries that have much more complex behavior where both the C# and JS sides can alter bound parameters, and "just moving the flag higher" isn't going to work for some of them if the component can re-render in parallel.
My understanding is that the single-thread emulation shouldn't allow my Blazor components to "re-render in parallel"
That is the wrong assumption. Async and multi-threaded are not the same. Blazor's rendering is async (interleaved) and yes, a new render can happen when you do an await anywhere. That it all happens on the same single thread is not so relevant.
This is covered by a Note in the docs that states:
Asynchronous actions performed in lifecycle events might not complete before a component is rendered.
So your original code did have a race condition and setting the flag earlier is indeed the fix.
but I have other components that wrap JS libraries that have much more complex behavior
You'll have to look at that case-by-case. I don't think there is a general solution except maybe to not use the AfterRender event to update data. It should be possible to move that logic to OnParamsSet.