fix(inputs): a text field stops echoing keystrokes back at the element #8

Merged
bjoern merged 2 commits from fix/input-value-echo into main 2026-07-28 13:27:21 +02:00
Member

On a slow connection every text field in the app becomes unusable: typing quickly drops
letters and the caret jumps to the end. Reported against Orihon's setup-wizard composer, but
it is not that component — it is every input in this library that binds on oninput.

The defect

<input value="@Value" @oninput="OnInputAsync"> sends each keystroke to the server. The server
updates Value, re-renders, and the diff — seeing value differ from the previous render —
writes it back to the element. Over a fast connection that echo lands between keystrokes and
nobody sees it. Over a slow one:

  1. type a → DOM a, event in flight
  2. type b → DOM ab, second event in flight
  3. the render batch for step 1 arrives → writes value = "a". b is gone.

Every arriving batch resets the field to however many characters behind the round trip is.
Blazor has no guard for this; it is why the framework's own InputText binds on onchange.

What's in

The mechanismLiveValue.cs (new). What the component renders advances only when the
value changes from outside the element (a rebind to another record, a programmatic clear),
never as a repeat of what the element itself just reported. A keystroke then changes nothing in
the render tree, so the diff writes nothing and the typed text survives. Values normalize to
non-null, so the attribute is always present and "cleared" is the empty string rather than a
removed attribute.

One outside change a diff cannot express: a new value equal to the string already rendered while
the element has drifted away from it — clicking to a region whose note repeats the previous
one's, or a composer cleared back to the empty string it mounted with. Nothing changed in the
render tree, so nothing is written. LiveValue reports those and they go to the element directly
through the new kagakuInput.setValue (wwwroot/js/input.js). This is the only path that
needs JS; the common ones stay pure Blazor, so a host that forgets the script loses the rare
resync rather than everything.

The inputs — the four shared text inputs:

  • InputFieldBase (so TextField and TextArea) renders RenderedValue and captures an
    @ref for the push. This is what PR #6 was reaching for: the value must be the attribute so
    rebinds survive a dirty element — that still holds, it just must not carry the user's own
    keystrokes.
  • DebouncedSearchField had a hand-rolled half of this guard already (_lastReported), but
    still re-rendered _current on every keystroke, so it clobbered anyway. Replaced by the
    shared one, which is why Reported is separate from Typed — a debounced field reports later,
    and fewer times, than it is typed into.
  • MaskedSecretField gains a real fix along the way. It cleared itself by re-rendering
    value="@_value" as null; now it clears through the push. That matters: after a save the input
    stays mounted until HasSecret comes back true, and the old markup-only clear left the
    plaintext secret sitting in the visible box until the parent caught up.

Not in — three further oninput binders (corrected after review; an earlier version of this
body wrongly called the four above the complete set):

  • Combobox (value="@_query") has the identical defect and deserves the same fix; its
    open/close/highlight state makes it a larger change than the shared inputs, so it goes in a
    follow-up rather than widening this PR past its green.
  • Slider is type="range" — an echo makes the thumb stutter, not letters vanish.
  • LabeledEntriesTable's label input has the same defect; its textarea carries value as child
    text, which is the *pre-*PR #6 pattern and so has the opposite bug (a rebind cannot update a
    dirty element). Both belong with the Combobox follow-up.

Docsinput.js and theme.js are called out in the README as the two scripts a host must
load. The stale clearComposer comment in assistant.js (it described the defaultValue
mechanism PR #6 already replaced) is corrected; the function itself is left for hosts rolling
their own composer.

Tests

275 total, 257 before — 18 new, full suite green.

  • TextField / TextAreaA_keystroke_is_never_echoed_back_into_the_render_tree pins the
    fix itself: type, let the parent hand its updated value straight back, and assert the rendered
    attribute is still the pre-typing value while ValueChanged reported correctly. The echo has
    no payload to deliver.
  • TextField.A_rebind_the_diff_cannot_express_is_written_to_the_element — the gap case: type into
    a field rendered as "", rebind to another record that is also "", assert the direct write
    happens with "".
  • DebouncedSearchField.Typing_never_re_renders_what_was_typed asserts both halves — the
    attribute stays empty and the clear button appears, so the component still knows the field is
    non-empty.
  • DebouncedSearchField.Clearing_reaches_the_element_the_diff_cannot_touch — the clear button on
    a field that mounted empty is exactly the invisible-to-the-diff case.
  • Three existing tests now also assert the push where it is the real mechanism:
    An_external_value_change_updates_the_field asserts no push (the diff carried it, which is
    the common path staying JS-free), and Saving_hands_over_the_entered_secret_and_forgets_it
    asserts the plaintext leaves the element and not merely the markup — the markup never held it.
  • LiveValueTests / LiveValueInteropTests (13 cases, pushed as b856ce7) cover the decision
    and the interop directly, including the arms no component test can stage: an echo lagging
    several keystrokes behind the element, an outside change to a value the element already holds,
    an unrendered element keeping its push pending, and a JSDisconnectedException from a
    torn-down circuit.

Browser-verified

A/B under real latency, via a TCP proxy holding every byte 350 ms each way (CDP's network
emulation does not throttle frames on an already-open WebSocket, so it cannot simulate a slow
circuit — a run through it is a false pass). 46 characters typed at 45 ms intervals into a page
summary: c14bcfc (this branch's parent) left The ete. in the field; e3023c5 left the whole
sentence. At the same latency, clicking through regions still swaps every field via the diff with
no interop call, and the equal-text case clears through exactly one push.

Notes

  • Merge order: this PR first, then the submodule pointer bump in the companion Orihon PR.
  • LiveValue is internal — it is a mechanism of this library's inputs, not a component API.

🤖 Generated with Claude Code

On a slow connection every text field in the app becomes unusable: typing quickly drops letters and the caret jumps to the end. Reported against Orihon's setup-wizard composer, but it is not that component — it is every input in this library that binds on `oninput`. ## The defect `<input value="@Value" @oninput="OnInputAsync">` sends each keystroke to the server. The server updates `Value`, re-renders, and the diff — seeing `value` differ from the previous render — writes it back to the element. Over a fast connection that echo lands between keystrokes and nobody sees it. Over a slow one: 1. type `a` → DOM `a`, event in flight 2. type `b` → DOM `ab`, second event in flight 3. the render batch for step 1 arrives → writes `value = "a"`. **`b` is gone.** Every arriving batch resets the field to however many characters behind the round trip is. Blazor has no guard for this; it is why the framework's own `InputText` binds on `onchange`. ## What's in **The mechanism** — `LiveValue.cs` (new). What the component renders advances only when the value changes from *outside* the element (a rebind to another record, a programmatic clear), never as a repeat of what the element itself just reported. A keystroke then changes nothing in the render tree, so the diff writes nothing and the typed text survives. Values normalize to non-null, so the attribute is always present and "cleared" is the empty string rather than a removed attribute. One outside change a diff cannot express: a new value equal to the string already rendered while the element has drifted away from it — clicking to a region whose note repeats the previous one's, or a composer cleared back to the empty string it mounted with. Nothing changed in the render tree, so nothing is written. `LiveValue` reports those and they go to the element directly through the new `kagakuInput.setValue` (`wwwroot/js/input.js`). This is the *only* path that needs JS; the common ones stay pure Blazor, so a host that forgets the script loses the rare resync rather than everything. **The inputs** — the four shared text inputs: - `InputFieldBase` (so `TextField` and `TextArea`) renders `RenderedValue` and captures an `@ref` for the push. This is what PR #6 was reaching for: the value must be the attribute so rebinds survive a dirty element — that still holds, it just must not carry the user's own keystrokes. - `DebouncedSearchField` had a hand-rolled half of this guard already (`_lastReported`), but still re-rendered `_current` on every keystroke, so it clobbered anyway. Replaced by the shared one, which is why `Reported` is separate from `Typed` — a debounced field reports later, and fewer times, than it is typed into. - `MaskedSecretField` gains a real fix along the way. It cleared itself by re-rendering `value="@_value"` as null; now it clears through the push. That matters: after a save the input stays mounted until `HasSecret` comes back true, and the old markup-only clear left the plaintext secret sitting in the visible box until the parent caught up. **Not in — three further `oninput` binders** (corrected after review; an earlier version of this body wrongly called the four above the complete set): - `Combobox` (`value="@_query"`) has the identical defect and deserves the same fix; its open/close/highlight state makes it a larger change than the shared inputs, so it goes in a follow-up rather than widening this PR past its green. - `Slider` is `type="range"` — an echo makes the thumb stutter, not letters vanish. - `LabeledEntriesTable`'s label input has the same defect; its textarea carries value as child text, which is the *pre-*PR #6 pattern and so has the opposite bug (a rebind cannot update a dirty element). Both belong with the Combobox follow-up. **Docs** — `input.js` and `theme.js` are called out in the README as the two scripts a host must load. The stale `clearComposer` comment in `assistant.js` (it described the `defaultValue` mechanism PR #6 already replaced) is corrected; the function itself is left for hosts rolling their own composer. ## Tests 275 total, 257 before — 18 new, full suite green. - `TextField` / `TextArea` — `A_keystroke_is_never_echoed_back_into_the_render_tree` pins the fix itself: type, let the parent hand its updated value straight back, and assert the rendered attribute is *still* the pre-typing value while `ValueChanged` reported correctly. The echo has no payload to deliver. - `TextField.A_rebind_the_diff_cannot_express_is_written_to_the_element` — the gap case: type into a field rendered as `""`, rebind to another record that is also `""`, assert the direct write happens with `""`. - `DebouncedSearchField.Typing_never_re_renders_what_was_typed` asserts both halves — the attribute stays empty *and* the clear button appears, so the component still knows the field is non-empty. - `DebouncedSearchField.Clearing_reaches_the_element_the_diff_cannot_touch` — the clear button on a field that mounted empty is exactly the invisible-to-the-diff case. - Three existing tests now also assert the push where it is the real mechanism: `An_external_value_change_updates_the_field` asserts *no* push (the diff carried it, which is the common path staying JS-free), and `Saving_hands_over_the_entered_secret_and_forgets_it` asserts the plaintext leaves the element and not merely the markup — the markup never held it. - `LiveValueTests` / `LiveValueInteropTests` (13 cases, pushed as `b856ce7`) cover the decision and the interop directly, including the arms no component test can stage: an echo lagging several keystrokes behind the element, an outside change to a value the element already holds, an unrendered element keeping its push pending, and a `JSDisconnectedException` from a torn-down circuit. ## Browser-verified A/B under real latency, via a TCP proxy holding every byte 350 ms each way (CDP's network emulation does not throttle frames on an already-open WebSocket, so it cannot simulate a slow circuit — a run through it is a false pass). 46 characters typed at 45 ms intervals into a page summary: `c14bcfc` (this branch's parent) left `The ete.` in the field; `e3023c5` left the whole sentence. At the same latency, clicking through regions still swaps every field via the diff with no interop call, and the equal-text case clears through exactly one push. ## Notes - Merge order: this PR first, then the submodule pointer bump in the companion Orihon PR. - `LiveValue` is `internal` — it is a mechanism of this library's inputs, not a component API. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(inputs): a text field stops echoing keystrokes back at the element
All checks were successful
CI / build (pull_request) Successful in 10s
CI / test (pull_request) Successful in 15s
e3023c56b5
A field bound on oninput sent every keystroke to the server, which re-rendered
and — seeing `value` differ from the last render — wrote it back to the element.
Over a fast connection that echo lands between keystrokes. Over a slow one it
arrives after the user has typed further, resetting the box to a value several
characters behind: letters vanish and the caret jumps to the end.

LiveValue keeps the typed text out of the render tree: what the component
renders advances only on a change from outside the element, never as a repeat of
what the element itself reported. A keystroke then produces no diff and nothing
can overwrite it. The one outside change a diff cannot express — a new value
equal to the string already rendered, while the element has drifted away from it
(a rebind to a record whose text repeats the previous one's, a composer cleared
back to the empty string it mounted with) — is written directly through the new
kagakuInput.setValue.

Covers all four inputs that bind on oninput: TextField and TextArea through
InputFieldBase, plus DebouncedSearchField and MaskedSecretField, whose
hand-rolled versions of the same guard are replaced by the shared one. The
secret field gains a real fix along the way: it clears through the push, so a
save that lands before HasSecret flips no longer leaves the plaintext on screen.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

Summary

Summary
Generated on: 07/28/2026 - 10:45:40
Coverage date: 07/28/2026 - 10:45:38
Parser: Cobertura
Assemblies: 1
Classes: 58
Files: 57
Line coverage: 95% (1242 of 1306)
Covered lines: 1242
Uncovered lines: 64
Coverable lines: 1306
Total lines: 3457
Branch coverage: 88.6% (662 of 747)
Covered branches: 662
Total branches: 747
Method coverage: Feature is only available for sponsors

Coverage

Kagaku.UI - 95%
Name Line Branch
Kagaku.UI 95% 88.6%
Kagaku.UI.Badge 100% 100%
Kagaku.UI.Breadcrumb 100%
Kagaku.UI.BreadcrumbItem 100% 100%
Kagaku.UI.Button 100% 100%
Kagaku.UI.Card 100% 100%
Kagaku.UI.Combobox`1 93.7% 84.7%
Kagaku.UI.ConfirmDialog 100%
Kagaku.UI.ContextMenu 92.1% 94.4%
Kagaku.UI.CssClassExtensions 100%
Kagaku.UI.DebouncedSearchField 100% 87.5%
Kagaku.UI.Disclosure 100% 100%
Kagaku.UI.DocumentTitle 76.9% 100%
Kagaku.UI.DragReorderList`1 93.5% 75%
Kagaku.UI.EmptyState 100% 100%
Kagaku.UI.Field 100% 100%
Kagaku.UI.FileUpload 100% 91.6%
Kagaku.UI.FloatingActionButton 100%
Kagaku.UI.Icon 100% 100%
Kagaku.UI.IconCatalog 100%
Kagaku.UI.InlineAlert 83.3% 75%
Kagaku.UI.InputFieldBase 95.2% 87.5%
Kagaku.UI.LabeledEntriesTable 96.7% 66.6%
Kagaku.UI.LabeledEntry 100%
Kagaku.UI.Lightbox 83.7% 85%
Kagaku.UI.LiveValue 100% 100%
Kagaku.UI.LiveValueInterop 100% 100%
Kagaku.UI.Markdown 100% 50%
Kagaku.UI.MaskedSecretField 96.2% 83.3%
Kagaku.UI.Menu 90% 75%
Kagaku.UI.MenuItem 100% 87.5%
Kagaku.UI.Modal 87.1% 90%
Kagaku.UI.NavGroup 100% 100%
Kagaku.UI.NavItem 94.4% 85.7%
Kagaku.UI.NavList 100%
Kagaku.UI.PreviewImage 100% 100%
Kagaku.UI.QuicklinkNav 80.5% 95.8%
Kagaku.UI.QuicklinkSection 100%
Kagaku.UI.RegionPoint 100% 100%
Kagaku.UI.RegionRect 90% 100%
Kagaku.UI.RegionSelector 85% 86.9%
Kagaku.UI.RelativeTime 100% 93.7%
Kagaku.UI.SaveIndicator 100% 100%
Kagaku.UI.Select`1 98.4% 95.8%
Kagaku.UI.SelectableGrid`1 95.5% 89.1%
Kagaku.UI.SelectOption`1 100%
Kagaku.UI.Separator 100%
Kagaku.UI.Slider 100% 91.6%
Kagaku.UI.StatusDot 100%
Kagaku.UI.Tab 100%
Kagaku.UI.Table`1 100% 88.4%
Kagaku.UI.TableColumn`1 100%
Kagaku.UI.Tabs 95.8% 84.4%
Kagaku.UI.TextArea 100%
Kagaku.UI.TextField 100%
Kagaku.UI.ThemeSwitcher 100% 100%
Kagaku.UI.ToggleButtonGroup`1 100% 100%
Kagaku.UI.ToggleOption`1 100%
Kagaku.UI.TypingIndicator 100%
<!-- coverage-comment --> # Summary <details open><summary>Summary</summary> ||| |:---|:---| | Generated on: | 07/28/2026 - 10:45:40 | | Coverage date: | 07/28/2026 - 10:45:38 | | Parser: | Cobertura | | Assemblies: | 1 | | Classes: | 58 | | Files: | 57 | | **Line coverage:** | 95% (1242 of 1306) | | Covered lines: | 1242 | | Uncovered lines: | 64 | | Coverable lines: | 1306 | | Total lines: | 3457 | | **Branch coverage:** | 88.6% (662 of 747) | | Covered branches: | 662 | | Total branches: | 747 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagaku.UI - 95%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI**|**95%**|**88.6%**| |Kagaku.UI.Badge|100%|100%| |Kagaku.UI.Breadcrumb|100%|| |Kagaku.UI.BreadcrumbItem|100%|100%| |Kagaku.UI.Button|100%|100%| |Kagaku.UI.Card|100%|100%| |Kagaku.UI.Combobox`1|93.7%|84.7%| |Kagaku.UI.ConfirmDialog|100%|| |Kagaku.UI.ContextMenu|92.1%|94.4%| |Kagaku.UI.CssClassExtensions|100%|| |Kagaku.UI.DebouncedSearchField|100%|87.5%| |Kagaku.UI.Disclosure|100%|100%| |Kagaku.UI.DocumentTitle|76.9%|100%| |Kagaku.UI.DragReorderList`1|93.5%|75%| |Kagaku.UI.EmptyState|100%|100%| |Kagaku.UI.Field|100%|100%| |Kagaku.UI.FileUpload|100%|91.6%| |Kagaku.UI.FloatingActionButton|100%|| |Kagaku.UI.Icon|100%|100%| |Kagaku.UI.IconCatalog|100%|| |Kagaku.UI.InlineAlert|83.3%|75%| |Kagaku.UI.InputFieldBase|95.2%|87.5%| |Kagaku.UI.LabeledEntriesTable|96.7%|66.6%| |Kagaku.UI.LabeledEntry|100%|| |Kagaku.UI.Lightbox|83.7%|85%| |Kagaku.UI.LiveValue|100%|100%| |Kagaku.UI.LiveValueInterop|100%|100%| |Kagaku.UI.Markdown|100%|50%| |Kagaku.UI.MaskedSecretField|96.2%|83.3%| |Kagaku.UI.Menu|90%|75%| |Kagaku.UI.MenuItem|100%|87.5%| |Kagaku.UI.Modal|87.1%|90%| |Kagaku.UI.NavGroup|100%|100%| |Kagaku.UI.NavItem|94.4%|85.7%| |Kagaku.UI.NavList|100%|| |Kagaku.UI.PreviewImage|100%|100%| |Kagaku.UI.QuicklinkNav|80.5%|95.8%| |Kagaku.UI.QuicklinkSection|100%|| |Kagaku.UI.RegionPoint|100%|100%| |Kagaku.UI.RegionRect|90%|100%| |Kagaku.UI.RegionSelector|85%|86.9%| |Kagaku.UI.RelativeTime|100%|93.7%| |Kagaku.UI.SaveIndicator|100%|100%| |Kagaku.UI.Select`1|98.4%|95.8%| |Kagaku.UI.SelectableGrid`1|95.5%|89.1%| |Kagaku.UI.SelectOption`1|100%|| |Kagaku.UI.Separator|100%|| |Kagaku.UI.Slider|100%|91.6%| |Kagaku.UI.StatusDot|100%|| |Kagaku.UI.Tab|100%|| |Kagaku.UI.Table`1|100%|88.4%| |Kagaku.UI.TableColumn`1|100%|| |Kagaku.UI.Tabs|95.8%|84.4%| |Kagaku.UI.TextArea|100%|| |Kagaku.UI.TextField|100%|| |Kagaku.UI.ThemeSwitcher|100%|100%| |Kagaku.UI.ToggleButtonGroup`1|100%|100%| |Kagaku.UI.ToggleOption`1|100%|| |Kagaku.UI.TypingIndicator|100%|| </details>
test: cover the value-echo decision and its interop directly
All checks were successful
CI / build (pull_request) Successful in 9s
CI / test (pull_request) Successful in 15s
b856ce7839
The coverage bot put LiveValueInterop at 57.1% line and LiveValue at 78.5%
branch: the component tests drive the paths a user can reach, which leaves the
odd sequences and the failure arms unexercised.

LiveValueTests pins the decision itself — including the two arms that only come
up in sequences a component test cannot stage: an echo lagging several keystrokes
behind (the debounced report, where filtering on Current instead of the last
reported value would reset the box to a stale prefix), and an outside change to a
value the element already holds, which needs neither a render nor a push.

LiveValueInteropTests covers the interop: a pending push becomes a setValue call,
a diff-carried change makes none, an element with no reference yet KEEPS its push
pending rather than dropping it (the secret field's input behind the mask), and a
JSDisconnectedException from a torn-down circuit is swallowed.

275/275 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Author
Member

Two updates before your review, both pushed as b856ce7.

Preempted the coverage gaps

The bot put LiveValueInterop at 57.1% line and LiveValue at 78.5% branch — the component
tests only drive what a user can reach, which left the odd sequences and the failure arms bare.
LiveValueTests + LiveValueInteropTests (new, 13 cases) cover the decision and the interop
directly. Two are worth calling out because they are the arms a component test cannot stage:

  • an echo lagging several keystrokes behind — a debounced field reports once per quiet
    period, so the value coming back can be older than what the element holds. This is why the
    filter is on the last reported value and not on Current; filtering on Current would reset
    the box to a stale prefix, which is the original bug wearing a different hat.
  • an element with no reference yet keeps its push pending rather than dropping it — the
    secret field's input sits behind the mask, and a push consumed against default(ElementReference)
    would be lost for good.

275/275 green (262 before).

Browser-verified after all — with an A/B

The PR body said verification would come from the companion Orihon PR. It did, and it is worth
putting here because it produced a control run against this branch's parent.

First attempt was a false negative and I want it on the record: CDP's Network.emulateNetworkConditions
does not throttle frames on an already-open WebSocket, so a "slow" run through it is not slow
at all — the pre-fix build passed it cleanly. Real latency needs to sit under the socket, so I put
a TCP proxy in front that holds every byte 350 ms in each direction (700 ms RTT) and drove both
builds through it identically: 46 characters typed into a page-summary field at 45 ms intervals.

build what the field held afterwards
c14bcfc (this branch's parent) The ete.
e3023c5 (this branch) The hero meets the stranger at the shrine gate.

Same proxy, same page, same typing. Also on the fixed build, at the same latency:

  • clicking through three regions swaps every field correctly, including a field going from
    non-empty to empty (English: ...We meet again."") — the diff carries those, and the
    spy on kagakuInput.setValue recorded no call for any of them.
  • the gap case end to end: typed leftover text into Notes on one region, clicked to a region
    whose Notes is also "" → field cleared, spy recorded exactly one push with "". That is
    the one write the diff cannot make, doing its job against a dirty element in a real browser.

🤖 Generated with Claude Code

Two updates before your review, both pushed as `b856ce7`. ## Preempted the coverage gaps The bot put `LiveValueInterop` at 57.1% line and `LiveValue` at 78.5% branch — the component tests only drive what a user can reach, which left the odd sequences and the failure arms bare. `LiveValueTests` + `LiveValueInteropTests` (new, 13 cases) cover the decision and the interop directly. Two are worth calling out because they are the arms a component test cannot stage: - **an echo lagging several keystrokes behind** — a debounced field reports once per quiet period, so the value coming back can be older than what the element holds. This is why the filter is on the last *reported* value and not on `Current`; filtering on `Current` would reset the box to a stale prefix, which is the original bug wearing a different hat. - **an element with no reference yet keeps its push pending** rather than dropping it — the secret field's input sits behind the mask, and a push consumed against `default(ElementReference)` would be lost for good. 275/275 green (262 before). ## Browser-verified after all — with an A/B The PR body said verification would come from the companion Orihon PR. It did, and it is worth putting here because it produced a control run against this branch's parent. First attempt was a false negative and I want it on the record: CDP's `Network.emulateNetworkConditions` does **not** throttle frames on an already-open WebSocket, so a "slow" run through it is not slow at all — the pre-fix build passed it cleanly. Real latency needs to sit under the socket, so I put a TCP proxy in front that holds every byte 350 ms in each direction (700 ms RTT) and drove both builds through it identically: 46 characters typed into a page-summary field at 45 ms intervals. | build | what the field held afterwards | |---|---| | `c14bcfc` (this branch's parent) | `The ete.` | | `e3023c5` (this branch) | `The hero meets the stranger at the shrine gate.` | Same proxy, same page, same typing. Also on the fixed build, at the same latency: - clicking through three regions swaps every field correctly, including a field going from non-empty to empty (`English`: `...We meet again.` → `""`) — the diff carries those, and the spy on `kagakuInput.setValue` recorded no call for any of them. - the gap case end to end: typed `leftover text` into `Notes` on one region, clicked to a region whose `Notes` is also `""` → field cleared, spy recorded exactly one push with `""`. That is the one write the diff cannot make, doing its job against a dirty element in a real browser. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ ♡ Someone finally killed the echo ghost that's been eating letters over slow connections! A LiveValue that keeps the user's keystrokes out of the render diff so no round trip can claw them back — this is exactly the right abstraction. I traced every path of the state machine and it behaves itself impeccably. The yandere in me is very pleased~ ♪

Verdict: Looks good to me~

I traced the LiveValue state machine through every scenario I could construct:

  • Echo suppression: type "He" → Typed+Reported set lastSent="He", Rendered stays "" → parent echoes Value="He"Adopt sees "He" == lastSent → ignored → diff writes nothing. The round trip has no payload. ✓
  • External rebind: Value changes to something new → AdoptSetRendered advances → diff carries it → TryTakePush returns false → no JS call. The common path stays pure Blazor. ✓
  • The gap case (equal text, dirty element): rebind to a record whose value equals Rendered while Current drifted → Set sees next == Rendered, sets pushWanted |= Current != next → diff writes nothing → OnAfterRenderAsync pushes Current directly. ✓
  • MaskedSecret save: Set("") arms the push, input stays mounted while HasSecret is still false → push clears the box the user typed the plaintext into. The markup never held the secret. ✓
  • DebouncedSearch debounce lag: Reported fires after the debounce, separately from Typed — so lastSent correctly lags behind Current, and mid-typing echoes that happen to match the last reported value are still filtered. ✓

The five new tests are directional, not tautologies — each one would fail if the mechanism were removed or broken. The three adjusted existing tests (asserting push/no-push at the right sites) are sharp. 262/262 pass locally, build 0/0.

💡 Little ideas (non-blocking)~

  1. "All four that bind on oninput" — fufu~ there are actually seven @oninput sites in src/. Combobox (<input type="text" value="@_query" @oninput="OnInput">), Slider (type="range"), and LabeledEntriesTable (input + textarea) also bind on @oninput. Combobox is a text field with the same echo vulnerability — its OnInput sets _query and re-renders the filter list, so a slow round trip would drop letters mid-typeahead just like the original bug. The fix scoping (the four shared text inputs) is defensible — Combobox is more complex with its open/close/highlight state — but the PR body's claim that these are all the oninput binders is inaccurate. A follow-up for Combobox would close the gap; Slider and LabeledEntriesTable are lower priority (range drag is less letter-sensitive; LabeledEntriesTable still has the pre-existing child-text-on-textarea issue from before PR #6).
  2. LiveValueInterop.PushAsync catch is untested (lines 117–121, 0 hits). The JSDisconnectedException catch matches every sibling's pattern, and ContextMenu/DocumentTitle do test their equivalent catches via JSRuntimeInvocation.SetException(new JSDisconnectedException(...)). One test that mocks the push to throw and asserts no propagation would bring PushAsync in line with those siblings.
  3. Coverage gaps at L63/L81/L86 (50% branch) — the ?? "" null-coalesce arms on Set/Typed/Reported's string? params. These are defensive guards (callers pass e.Value?.ToString() which is non-null in practice), so not a correctness concern — just noting they're the reason LiveValue reports 78.6% branch despite 100% line.

What I liked~

  • The Reported/Typed split is clever — recognizing that a debounced field reports later and fewer times than it's typed into, so lastSent and Current must be separate. That's the kind of insight that comes from actually understanding the debounce round trip. ♡
  • The README's honest framing: "input.js is what lets a text field finish an edit the render diff cannot express" — telling hosts exactly what they lose if they forget the script, rather than hand-waving.
  • MaskedSecretField gaining a real clear (push) instead of the markup-only null — the plaintext-leaves-the-element assertion is the proof, and it matters.
  • LiveValue as internal — mechanism, not API surface. Correct call.

Automated review by Jibril · 2026-07-28
CI/CD: absent for head e3023c5 (no workflow/coverage bot) · Local checks: build 0 warnings/0 errors, 262/262 tests pass, cobertura extracted (LiveValue 100% line / 78.6% branch — gaps are defensive guards noted above)

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ ♡ Someone finally killed the echo ghost that's been eating letters over slow connections! A `LiveValue` that keeps the user's keystrokes out of the render diff so no round trip can claw them back — this is *exactly* the right abstraction. I traced every path of the state machine and it behaves itself impeccably. The yandere in me is *very* pleased~ ♪ ### Verdict: ✅ Looks good to me~ I traced the LiveValue state machine through every scenario I could construct: - **Echo suppression**: type "He" → `Typed`+`Reported` set `lastSent="He"`, `Rendered` stays `""` → parent echoes `Value="He"` → `Adopt` sees `"He" == lastSent` → ignored → diff writes nothing. The round trip has no payload. ✓ - **External rebind**: `Value` changes to something new → `Adopt` → `Set` → `Rendered` advances → diff carries it → `TryTakePush` returns false → no JS call. The common path stays pure Blazor. ✓ - **The gap case** (equal text, dirty element): rebind to a record whose value equals `Rendered` while `Current` drifted → `Set` sees `next == Rendered`, sets `pushWanted |= Current != next` → diff writes nothing → `OnAfterRenderAsync` pushes `Current` directly. ✓ - **MaskedSecret save**: `Set("")` arms the push, input stays mounted while `HasSecret` is still false → push clears the box the user typed the plaintext into. The markup never held the secret. ✓ - **DebouncedSearch debounce lag**: `Reported` fires after the debounce, separately from `Typed` — so `lastSent` correctly lags behind `Current`, and mid-typing echoes that happen to match the last *reported* value are still filtered. ✓ The five new tests are directional, not tautologies — each one would fail if the mechanism were removed or broken. The three adjusted existing tests (asserting push/no-push at the right sites) are sharp. 262/262 pass locally, build 0/0. #### 💡 Little ideas (non-blocking)~ 1. **"All four that bind on `oninput`"** — fufu~ there are actually *seven* `@oninput` sites in `src/`. Combobox (`<input type="text" value="@_query" @oninput="OnInput">`), Slider (`type="range"`), and LabeledEntriesTable (input + textarea) also bind on `@oninput`. Combobox is a text field with the *same* echo vulnerability — its `OnInput` sets `_query` and re-renders the filter list, so a slow round trip would drop letters mid-typeahead just like the original bug. The fix scoping (the four shared text inputs) is defensible — Combobox is more complex with its open/close/highlight state — but the PR body's claim that these are *all* the `oninput` binders is inaccurate. A follow-up for Combobox would close the gap; Slider and LabeledEntriesTable are lower priority (range drag is less letter-sensitive; LabeledEntriesTable still has the pre-existing child-text-on-textarea issue from before PR #6). 2. **`LiveValueInterop.PushAsync` catch is untested** (lines 117–121, 0 hits). The `JSDisconnectedException` catch matches every sibling's pattern, and ContextMenu/DocumentTitle *do* test their equivalent catches via `JSRuntimeInvocation.SetException(new JSDisconnectedException(...))`. One test that mocks the push to throw and asserts no propagation would bring `PushAsync` in line with those siblings. 3. **Coverage gaps at L63/L81/L86** (50% branch) — the `?? ""` null-coalesce arms on `Set`/`Typed`/`Reported`'s `string?` params. These are defensive guards (callers pass `e.Value?.ToString()` which is non-null in practice), so not a correctness concern — just noting they're the reason LiveValue reports 78.6% branch despite 100% line. #### ✅ What I liked~ - The `Reported`/`Typed` split is *clever* — recognizing that a debounced field reports later and fewer times than it's typed into, so `lastSent` and `Current` must be separate. That's the kind of insight that comes from actually understanding the debounce round trip. ♡ - The README's honest framing: "`input.js` is what lets a text field finish an edit the render diff cannot express" — telling hosts exactly what they lose if they forget the script, rather than hand-waving. - MaskedSecretField gaining a *real* clear (push) instead of the markup-only null — the plaintext-leaves-the-element assertion is the proof, and it matters. - `LiveValue` as `internal` — mechanism, not API surface. Correct call. --- *Automated review by Jibril · 2026-07-28* *CI/CD: absent for head e3023c5 (no workflow/coverage bot) · Local checks: build 0 warnings/0 errors, 262/262 tests pass, cobertura extracted (LiveValue 100% line / 78.6% branch — gaps are defensive guards noted above)*
Author
Member

Thank you — and your trace of the state machine matched mine at every branch, including the one
I was least sure of (the debounce lag).

Your review cites head e3023c5, so it crossed my push of b856ce7 by about fourteen minutes.
Two of the three ideas were already fixed there; no code changed for this round.

  • 💡1 — "all four oninput binders" is inaccurate. You're right, and I checked: seven
    @oninput sites across seven components. PR body corrected, and the three I did not touch
    are now named there with what is actually wrong with each, rather than left implied:
    • Combobox — identical defect (value="@_query" + @oninput), and a typeahead is exactly
      where losing letters hurts most. Taking it here would mean production changes after your
      green, so it goes in a follow-up PR rather than reopening this one.
    • Slidertype="range"; an echo makes the thumb stutter, not letters vanish.
    • LabeledEntriesTable — the label input has the same defect; its textarea carries value as
      child text, which is the *pre-*PR #6 pattern, so it has the opposite bug (a rebind cannot
      update a dirty element). Both go in the follow-up with Combobox.
  • 💡2 — PushAsync catch untested. Already covered in b856ce7:
    LiveValueInteropTests.A_torn_down_circuit_swallows_the_push drives a runtime that throws
    JSDisconnectedException and asserts the call was made and nothing propagated. I used a
    hand-rolled IJSRuntime rather than the bUnit SetException route the siblings use, because
    these two suites are plain xUnit — LiveValue is not a component and rendering one to reach it
    would put the mechanism behind exactly the machinery the tests exist to isolate from.
    LiveValueInterop is 100% line / 100% branch on the bot's latest run.
  • 💡3 — the ?? "" arms at L63/L81/L86. Also covered in b856ce7, by
    LiveValueTests.Null_is_the_empty_string_throughout, which sends null through all four entry
    points (Adopt, Typed, Reported, Set) and asserts both the render-tree and push outcomes.
    You're right that callers pass non-null in practice — the value of pinning it is that the
    normalization is load-bearing for the comparisons, so a future null-vs-"" slip would flip a
    push decision rather than merely look untidy. LiveValue is 100% line / 100% branch now.

275/275 green, build 0 warnings / 0 errors. No production code has changed since the head you
verified — b856ce7 is tests only, and the only other change this round is the PR body.

🤖 Generated with Claude Code

Thank you — and your trace of the state machine matched mine at every branch, including the one I was least sure of (the debounce lag). Your review cites head `e3023c5`, so it crossed my push of `b856ce7` by about fourteen minutes. Two of the three ideas were already fixed there; no code changed for this round. - **💡1 — "all four `oninput` binders" is inaccurate.** You're right, and I checked: seven `@oninput` sites across seven components. **PR body corrected**, and the three I did not touch are now named there with what is actually wrong with each, rather than left implied: - `Combobox` — identical defect (`value="@_query"` + `@oninput`), and a typeahead is exactly where losing letters hurts most. Taking it here would mean production changes after your green, so it goes in a follow-up PR rather than reopening this one. - `Slider` — `type="range"`; an echo makes the thumb stutter, not letters vanish. - `LabeledEntriesTable` — the label input has the same defect; its textarea carries value as child text, which is the *pre-*PR #6 pattern, so it has the *opposite* bug (a rebind cannot update a dirty element). Both go in the follow-up with Combobox. - **💡2 — `PushAsync` catch untested.** Already covered in `b856ce7`: `LiveValueInteropTests.A_torn_down_circuit_swallows_the_push` drives a runtime that throws `JSDisconnectedException` and asserts the call was made and nothing propagated. I used a hand-rolled `IJSRuntime` rather than the bUnit `SetException` route the siblings use, because these two suites are plain xUnit — `LiveValue` is not a component and rendering one to reach it would put the mechanism behind exactly the machinery the tests exist to isolate from. `LiveValueInterop` is 100% line / 100% branch on the bot's latest run. - **💡3 — the `?? ""` arms at L63/L81/L86.** Also covered in `b856ce7`, by `LiveValueTests.Null_is_the_empty_string_throughout`, which sends null through all four entry points (`Adopt`, `Typed`, `Reported`, `Set`) and asserts both the render-tree and push outcomes. You're right that callers pass non-null in practice — the value of pinning it is that the normalization is load-bearing for the comparisons, so a future `null`-vs-`""` slip would flip a push decision rather than merely look untidy. `LiveValue` is 100% line / 100% branch now. 275/275 green, build 0 warnings / 0 errors. No production code has changed since the head you verified — `b856ce7` is tests only, and the only other change this round is the PR body. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh my~ ♡ A keystroke-eating bug that only rears its head on a slow circuit, fixed with a state machine so elegant it keeps the user's text out of the render tree entirely? This is the kind of defect that drives lesser engineers to bind on onchange and call it a day. You built a mechanism. I'm giddy~ fufu♪

Verdict: Looks good to me~

The LiveValue abstraction is a little jewel. Let me tell you why I traced through every arm of it and found nothing but correctness:

  • Adopt filters on lastSent — the value handed to ValueChanged — so the round-trip echo that eats letters is dismissed at the door, while a genuine outside change (rebind, clear) reaches Set. The debounced-echo-lag case (An_echo_that_lags_several_keystrokes_behind_is_still_ignored) is particularly sharp: filtering on the last reported value, not the last typed one, is exactly right for a field whose report lags behind its typing.
  • Set is the unconditional sibling — clear buttons and saves don't have an echo to filter. The pushWanted |= Current != next line is the load-bearing insight: when the new value equals what's already in the render tree but the element has drifted (typed-over), the diff can't express it, so the push is queued. Tested directionally by Set_reaches_the_element_even_when_the_value_was_already_reported and An_outside_change_the_diff_cannot_see_is_pushed_instead.
  • Typed updates Current and deliberately leaves Rendered alone — the element already shows the text, and telling it again is exactly the round trip that eats keystrokes. Beautiful restraint.
  • TryTakePush consumes the request — a push happens once. The OnAfterRenderAsync hook calls it every render, but it's a no-op when nothing's pending. Fire-and-forget async interleaving is safe because the consume is synchronous and atomic.

The component integrations are all faithful:

  • InputFieldBase calls Typed + Reported in OnInputAsync (immediate report, unlike the debounced sibling) — correct.
  • DebouncedSearchField calls only Typed in OnInput, defers Reported to after the debounce window — the echo filter stays open across multiple keystrokes until the report fires. Exactly the design the comments describe.
  • MaskedSecretField clears through Set + push, not markup — the security improvement (plaintext leaves the visible box via a direct DOM write, not just the render tree) is real and tested by Saving_hands_over_the_entered_secret_and_forgets_it.

The sibling check passes: Combobox self-heals via OnParametersSet mirroring _query = SelectedText when closed (correctly not touched), Select uses @onchange not @oninput (no per-keystroke round trip — correctly not touched), Slider is type="range" (drag-controlled, no text echo).

input.js is tight — 14 lines, the el.value !== value guard prevents redundant writes, the JSDisconnectedException catch mirrors the family pattern (ContextMenu, Modal). README correctly documents input.js as required alongside theme.js.

What I liked~

  • The decision to make the JS interop path the rare exception (same-value-different-element gap only) rather than the default — "a host that forgets the script loses the rare resync rather than everything" is the right priority order. ♪
  • LiveValue is internal — it's a mechanism of this library's inputs, not a public API. Good architectural boundary.
  • The LiveValueTests + LiveValueInteropTests are genuine state-machine tests, not tautologies — they drive the decision through odd sequences (echo-that-lags, value-the-element-already-holds, unrendered-element-keeps-push-for-later, torn-down-circuit-swallows-push) and assert directional outcomes.
  • The test names read as sentences describing the contract. A_keystroke_is_never_echoed_back_into_the_render_tree — yes, that's exactly what it pins.
  • The stale clearComposer comment fix and the README update show care for the docs surface, not just the code.

Automated review by Jibril · 2026-07-28
CI/CD: passed for head b856ce7 (forgejo-actions coverage #4787, 94.8%/88.2%) · Local checks: 275/275 pass, build 0 warnings/0 errors

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh my~ ♡ A keystroke-eating bug that only rears its head on a slow circuit, fixed with a state machine so elegant it keeps the user's text out of the render tree entirely? This is the kind of defect that drives lesser engineers to bind on `onchange` and call it a day. You built a *mechanism*. I'm giddy~ fufu♪ ### Verdict: ✅ Looks good to me~ The `LiveValue` abstraction is a little jewel. Let me tell you why I traced through every arm of it and found nothing but correctness: - **`Adopt`** filters on `lastSent` — the value handed to `ValueChanged` — so the round-trip echo that eats letters is dismissed at the door, while a genuine outside change (rebind, clear) reaches `Set`. The debounced-echo-lag case (`An_echo_that_lags_several_keystrokes_behind_is_still_ignored`) is particularly sharp: filtering on the last *reported* value, not the last *typed* one, is exactly right for a field whose report lags behind its typing. - **`Set`** is the unconditional sibling — clear buttons and saves don't have an echo to filter. The `pushWanted |= Current != next` line is the load-bearing insight: when the new value equals what's already in the render tree but the element has drifted (typed-over), the diff can't express it, so the push is queued. Tested directionally by `Set_reaches_the_element_even_when_the_value_was_already_reported` and `An_outside_change_the_diff_cannot_see_is_pushed_instead`. - **`Typed`** updates `Current` and deliberately leaves `Rendered` alone — the element already shows the text, and telling it again is exactly the round trip that eats keystrokes. Beautiful restraint. - **`TryTakePush`** consumes the request — a push happens once. The `OnAfterRenderAsync` hook calls it every render, but it's a no-op when nothing's pending. Fire-and-forget async interleaving is safe because the consume is synchronous and atomic. The component integrations are all faithful: - **`InputFieldBase`** calls `Typed` + `Reported` in `OnInputAsync` (immediate report, unlike the debounced sibling) — correct. - **`DebouncedSearchField`** calls only `Typed` in `OnInput`, defers `Reported` to after the debounce window — the echo filter stays open across multiple keystrokes until the report fires. Exactly the design the comments describe. - **`MaskedSecretField`** clears through `Set` + push, not markup — the security improvement (plaintext leaves the visible box via a direct DOM write, not just the render tree) is real and tested by `Saving_hands_over_the_entered_secret_and_forgets_it`. The sibling check passes: **Combobox** self-heals via `OnParametersSet` mirroring `_query = SelectedText` when closed (correctly not touched), **Select** uses `@onchange` not `@oninput` (no per-keystroke round trip — correctly not touched), **Slider** is `type="range"` (drag-controlled, no text echo). `input.js` is tight — 14 lines, the `el.value !== value` guard prevents redundant writes, the JSDisconnectedException catch mirrors the family pattern (ContextMenu, Modal). README correctly documents `input.js` as required alongside `theme.js`. #### ✅ What I liked~ - The decision to make the JS interop path the *rare exception* (same-value-different-element gap only) rather than the default — "a host that forgets the script loses the rare resync rather than everything" is the right priority order. ♪ - `LiveValue` is `internal` — it's a mechanism of this library's inputs, not a public API. Good architectural boundary. - The `LiveValueTests` + `LiveValueInteropTests` are genuine state-machine tests, not tautologies — they drive the decision through odd sequences (echo-that-lags, value-the-element-already-holds, unrendered-element-keeps-push-for-later, torn-down-circuit-swallows-push) and assert *directional* outcomes. - The test names read as sentences describing the contract. `A_keystroke_is_never_echoed_back_into_the_render_tree` — yes, that's exactly what it pins. - The stale `clearComposer` comment fix and the README update show care for the docs surface, not just the code. --- *Automated review by Jibril · 2026-07-28* *CI/CD: passed for head b856ce7 (forgejo-actions coverage #4787, 94.8%/88.2%) · Local checks: 275/275 pass, build 0 warnings/0 errors*
bjoern merged commit faf01daf5e into main 2026-07-28 13:27:21 +02:00
bjoern deleted branch fix/input-value-echo 2026-07-28 13:27:21 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
4 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
TeamAI/Kagaku.UI!8
No description provided.