Here is a simple Blazor counter example (try it online):
<label>Count:</label>
<button @onclick=Increment>@count times!</button><br>
A: <input @oninput=OnChange value="@count"><br>
B: <input @bind-value=count @bind-value:event="oninput">
@code {
int count = 1;
void Increment() => count ;
void OnChange(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out var v)) count = v;
else StateHasChanged();
}
}
For input
element A, I want to replicate the behavior of input
element B, but without using the bind-xxx
-style data binding attributes.
E.g., when I type 123x
inside A, I want it to revert back to 123
automatically, as it happens with B.
I've tried StateHasChanged
but it doesn't work, I suppose, because the count
property hasn't actually changed.
So, basically I need to re-render A to undo the user input, even thought the state hasn't changed.
How can I do that without the bind-xxx
magic? Do I need to resort to JS interop?
Updated, here's the same thing in React (try it online), to compare:
function App() {
let [count, setCount] = useState(0);
const handleClick = () => setCount((count) => count 1);
const handleChange = (e) => {
const userValue = e.target.value;
let newValue = userValue ? parseInt(userValue) : 0;
if (isNaN(newValue)) newValue = count;
setCount(newValue);
};
return (
<>
Count: <button onClick={handleClick}>{count}</button>
<br />
A: <input value={count} onInput={handleChange} />
<br />
</>
);
}
And here's how to do it manually in Svelte (try it online):
<script>
let count = 0;
const handleClick = () => count ;
const handleChange = async e => {
const userValue = e.target.value;
let newValue = userValue? parseInt(userValue): 0;
if (isNaN(newValue)) newValue = count;
if (newValue === count)
e.target.value = count; // undo the input
else
count = newValue;
};
</script>
Count: <button on:click={handleClick}>{count}</button>
<br />
A: <input value={count} on:input={handleChange} />
<br />
Note Svelte also bind:value
as an automatic option, similar to Blazor.
CodePudding user response:
Here's a modified version of your code that does what you want it to:
@page "/"
<label>Count:</label>
<button @onclick=Increment>@count times!</button>
<br>
A:
<input @oninput=OnChange value="@count">
<br>
B:
<input @bind-value=count @bind-value:event="oninput">
@code {
int count = 1;
void Increment() => count ;
async Task OnChange(ChangeEventArgs e)
{
var oldvalue = count;
var isNewValue = int.TryParse(e.Value?.ToString(), out var v);
if (isNewValue)
count = v;
else
{
count = 0;
// this one line may precipitate a few commments!
await Task.Yield();
count = oldvalue;
}
}
}
So "What's going on?"
Firstly razor code is pre-compiled into C# classes, so what you see is not what actually gets run as code. I won't go into that here, there's plenty of articles online.
value="@count"
is a one way binding, and is passed as a string.
You may change the actual value in the input on screen, but in the Blazor component the value is still the old value. There's been no callback to tell it otherwise.
When you type 22x after 22, OnChange
doesn't update count
. As far as the Renderer is concerned it hasn't changed so it don't need to update that bit of the the DOM. We have a mismatch between the Renderer DOM and the actual DOM!
OnChange
changes to async Task
and it now:
- Gets a copy of the old value
- If the new value is a number updates
count
. - If it's not a number
- Sets
count
to another value - in this case zero. - Yields. The Blazor Component Event handler calls
StateHasChanged
and yields. This gives the Renderer thread time to service it's queue and re-render. The input in momentarily zero. - Set
count
to the old value. - Returns Task complete. The Blazor Component Event handler runs to completion calling
StateHasChanged
a second time. The Renderer updates the display value.
Update on why Task.Yield is used
The basic Blazor Component event handler [BCEH from this point] looks like this:
var task = InvokeAsync(EventMethod);
StateHasChanged();
if (!task.IsCompleted)
{
await task;
StateHasChanged();
}
Put OnChange
into this context.
var task = InvokeAsync(EventMethod)
runs OnChange
. Which starts to run synchronously.
If isNewValue
is false it's sets count
to 0 and then yields through Task.Yield
passing an incomplete Task back to BCEH. This can then progress and runs StateHasChanged
which queues a render fragment onto the Renderer's queue. Note it doesn't actually render the component, just queues the render fragment. At this point BCEH is hogging the thread so the Renderer can't actually service it's queue. It then checks task
to see if it's completed.
If it's complete BCEH completes, the Renderer gets some thread time and renders the component.
If it's still running - it will be as we've kicked it to the back of the thread queue with Task.Yield - BCEH awaits it and yields. The Renderer gets some thread time and renders the component. OnChange
then completes, BCEH gets a completed Task, stacks another render on the Render's queue with a call to StateHasChanged
and completes. The Renderer, now with thread time services it's queue and renders the component a second time.
Note some people prefer to use Task.Delay(1)
, because there's some discussion on exactly how Task.Yield
works!
CodePudding user response:
Below is what I've come up with (try online). I wish ChangeEventArgs.Value
worked both ways, but it doesn't. Without it, I can only think of this JS.InvokeVoidAsync
hack:
@inject IJSRuntime JS
<label>Count:</label>
<button @onclick=Increment>@count times!</button>
<br>
A:
<input @oninput=OnChange value="@count" id="@id">
<br>
B:
<input @bind-value=count @bind-value:event="oninput">
@code {
int count = 1;
string id = Guid.NewGuid().ToString("N");
void Increment() => count ;
async Task OnChange(ChangeEventArgs e)
{
if (int.TryParse(e.Value?.ToString(), out var v))
count = v;
else
{
// this doesn't work
e.Value = count;
// using JS interop as a workaround
await JS.InvokeVoidAsync("eval",
$"document.getElementById('{id}').value = Number('{count}')");
}
}
}