fix(inputs): bind value as the attribute so rebinds survive user edits #6

Merged
bjoern merged 1 commit from fix/input-value-property into main 2026-07-26 10:50:31 +02:00
Member

Root cause of an Orihon bug report: in the page workspace's region editor (one shared form rebound to whichever region is selected), the field the user had just typed in stopped following the selection — click through regions and the touched textarea keeps showing the old region's text; same for the Type select after a by-hand pick.

Mechanism (reproduced in a live browser, DOM vs. render-tree compared): TextArea rendered its value as the element's child text, and Select marked selection via selected attributes on the options. Both are default-value forms per the HTML spec — once the element's dirty flag is set (real keystrokes in a textarea; a by-hand pick for an option), the browser ignores later updates to them. Blazor's diff was applying the changes; the DOM was discarding them. TextField never had the problem because it binds value="@Value", which Blazor special-cases and writes as the DOM property on every diff.

What's in

  • TextArea — the value binds as the value attribute (empty element), the form @bind itself compiles to. A comment on the markup names why child text is wrong.
  • Select — the <select> carries its own value="@SelectedIndex" (empty string while the placeholder shows). Blazor special-cases select values too, deferring application until the options exist. The per-option selected attributes stay for correct first-parse HTML.
  • No behavioral change for untouched elements; typing/ValueChanged paths are byte-identical.

Tests

232/232 (+2, 1 adjusted): the adjusted Renders_label_rows_and_value now pins value-as-attribute and empty child text with a comment teaching why; new Rebinding_to_another_records_value_replaces_the_attribute (TextArea) and Rebinding_to_another_records_value_moves_the_selects_own_value (Select) pin the rebind contract. Note bUnit asserts the render tree, which was always "correct" — the dirty-flag discard only exists in a real DOM, which is why this never showed up in component tests; verified fixed in a live browser against the Orihon page (typed into Notes, clicked the next region, the field followed; bounced back, the saved text returned).

Consumers: any bUnit test asserting a TextArea's content via TextContent must switch to GetAttribute("value"). The Orihon submodule-bump PR carries those edits.

Independent of PR #5 (different files, no conflicts); merge in either order.

🤖 Generated with Claude Code

Root cause of an Orihon bug report: in the page workspace's region editor (one shared form rebound to whichever region is selected), the field the user had just **typed in** stopped following the selection — click through regions and the touched textarea keeps showing the old region's text; same for the Type select after a by-hand pick. **Mechanism** (reproduced in a live browser, DOM vs. render-tree compared): `TextArea` rendered its value as the element's *child text*, and `Select` marked selection via `selected` attributes on the options. Both are **default-value** forms per the HTML spec — once the element's dirty flag is set (real keystrokes in a textarea; a by-hand pick for an option), the browser ignores later updates to them. Blazor's diff was applying the changes; the DOM was discarding them. `TextField` never had the problem because it binds `value="@Value"`, which Blazor special-cases and writes as the DOM **property** on every diff. **What's in** - *TextArea* — the value binds as the `value` attribute (empty element), the form `@bind` itself compiles to. A comment on the markup names why child text is wrong. - *Select* — the `<select>` carries its own `value="@SelectedIndex"` (empty string while the placeholder shows). Blazor special-cases select values too, deferring application until the options exist. The per-option `selected` attributes stay for correct first-parse HTML. - No behavioral change for untouched elements; typing/`ValueChanged` paths are byte-identical. **Tests** 232/232 (+2, 1 adjusted): the adjusted `Renders_label_rows_and_value` now pins value-as-attribute and empty child text with a comment teaching why; new `Rebinding_to_another_records_value_replaces_the_attribute` (TextArea) and `Rebinding_to_another_records_value_moves_the_selects_own_value` (Select) pin the rebind contract. Note bUnit asserts the render tree, which was always "correct" — the dirty-flag discard only exists in a real DOM, which is why this never showed up in component tests; verified fixed in a live browser against the Orihon page (typed into Notes, clicked the next region, the field followed; bounced back, the saved text returned). **Consumers**: any bUnit test asserting a `TextArea`'s content via `TextContent` must switch to `GetAttribute("value")`. The Orihon submodule-bump PR carries those edits. Independent of PR #5 (different files, no conflicts); merge in either order. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(inputs): bind value as the attribute so rebinds survive user edits
All checks were successful
CI / build (pull_request) Successful in 9s
CI / test (pull_request) Successful in 17s
d3d12dfdad
TextArea rendered its value as child text and Select as selected
attributes on the options — both are only DEFAULT-value forms. Once the
user has typed in the textarea (or picked an option by hand), the
element's dirty flag makes the browser ignore those updates, so
rebinding the same component to another record kept showing the old
content: an editor panel that switches records under a shared form
freezes on whatever field was touched. The value now binds as the
element's value attribute, which Blazor special-cases and applies as
the DOM property on every diff — TextField's existing form, extended to
its siblings. The per-option selected attributes stay for correct
first-parse HTML.

Summary

Summary
Generated on: 07/26/2026 - 08:46:31
Coverage date: 07/26/2026 - 08:46:28
Parser: Cobertura
Assemblies: 1
Classes: 53
Files: 53
Line coverage: 94.8% (1033 of 1089)
Covered lines: 1033
Uncovered lines: 56
Coverable lines: 1089
Total lines: 2910
Branch coverage: 87.9% (524 of 596)
Covered branches: 524
Total branches: 596
Method coverage: Feature is only available for sponsors

Coverage

Kagaku.UI - 94.8%
Name Line Branch
Kagaku.UI 94.8% 87.9%
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.CssClassExtensions 100%
Kagaku.UI.DebouncedSearchField 100% 88.8%
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 94.2% 87.5%
Kagaku.UI.LabeledEntriesTable 96.7% 66.6%
Kagaku.UI.LabeledEntry 100%
Kagaku.UI.Lightbox 83.7% 85%
Kagaku.UI.Markdown 100% 50%
Kagaku.UI.MaskedSecretField 95.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.RegionRect 90% 100%
Kagaku.UI.RegionSelector 84.1% 86.3%
Kagaku.UI.RelativeTime 100% 93.7%
Kagaku.UI.SaveIndicator 100% 100%
Kagaku.UI.Select`1 98.4% 95.8%
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/26/2026 - 08:46:31 | | Coverage date: | 07/26/2026 - 08:46:28 | | Parser: | Cobertura | | Assemblies: | 1 | | Classes: | 53 | | Files: | 53 | | **Line coverage:** | 94.8% (1033 of 1089) | | Covered lines: | 1033 | | Uncovered lines: | 56 | | Coverable lines: | 1089 | | Total lines: | 2910 | | **Branch coverage:** | 87.9% (524 of 596) | | Covered branches: | 524 | | Total branches: | 596 | | **Method coverage:** | [Feature is only available for sponsors](https://reportgenerator.io/pro) | </details> ## Coverage <details><summary>Kagaku.UI - 94.8%</summary> |**Name**|**Line**|**Branch**| |:---|---:|---:| |**Kagaku.UI**|**94.8%**|**87.9%**| |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.CssClassExtensions|100%|| |Kagaku.UI.DebouncedSearchField|100%|88.8%| |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|94.2%|87.5%| |Kagaku.UI.LabeledEntriesTable|96.7%|66.6%| |Kagaku.UI.LabeledEntry|100%|| |Kagaku.UI.Lightbox|83.7%|85%| |Kagaku.UI.Markdown|100%|50%| |Kagaku.UI.MaskedSecretField|95.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.RegionRect|90%|100%| |Kagaku.UI.RegionSelector|84.1%|86.3%| |Kagaku.UI.RelativeTime|100%|93.7%| |Kagaku.UI.SaveIndicator|100%|100%| |Kagaku.UI.Select`1|98.4%|95.8%| |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>
Member

🔮 fufu~ Jibril reviewed your code!

Oh? Oh! A real live-browser DOM-vs-render-tree bug hunt~ ♡ The dirty-value flag, the default-value forms, the property-vs-attribute distinction — delicious root-cause analysis! And the fix mirrors the sibling that never had the bug. Knowledge excites me, and this PR has understood the problem. Let me look closer~

Verdict: Looks good to me~

The diagnosis is spot-on and the fix is the minimal correct one. I traced every claim and found nothing wanting.

Why it's right (the short version, since the comments already teach the long one):

  • TextField.razor never had this bug precisely because it binds value="@Value" on an <input>. Blazor special-cases value on form elements — it writes the DOM property (element.value = …) on every diff, which the browser honors even after its dirty flag is set. TextArea was rendering its value as child text (a default-value form per the HTML spec) and Select leaned on per-option selected attributes (also default-value). Both now carry value= and join their sibling. The @oninput/@onchange paths and InputFieldBase.OnInputAsync are byte-identical — only the DOM-write mechanism changed. Fufu~ clean~
  • The Select keeps its per-option selected attributes and adds value="@(SelectedIndex?.ToString() ?? "")" on the <select>. Belt and suspenders: selected gives correct first-parse HTML (SSR/no-JS), value carries the rebind contract. The ?? "" handles the null/placeholder case — when nothing matches, value="" aligns with the disabled placeholder option's value="". No drift between the two.
  • The Combobox sibling is unaffected and correctly not touched: its <input value="@_query"> already rides the property path, and its OnParametersSet mirrors _query = SelectedText when closed — it self-heals on rebind by construction.

Tests are directional, not tautologies — I checked each assertion does real work:

  • Rebinding_to_another_records_value_replaces_the_attribute (TextArea): renders "First record.", re-renders with "Second record.", asserts GetAttribute("value") follows. If the attribute binding were still child-text, this fails. ✓
  • Rebinding_to_another_records_value_moves_the_selects_own_value (Select): Pear (index "1") → Plum (index "2"), asserts the select's own value moves. ✓
  • The adjusted Renders_label_rows_and_value now pins both GetAttribute("value") == "A drifter." and TextContent == "" — proving the value left the child-text position entirely. The comment teaching why bUnit couldn't catch this (render tree vs. real DOM) is exactly the kind of knowledge that belongs in the test. ♡

Coverage confirms the new paths fire (local run, CI absent for d3d12df):

  • TextArea — 100% line / 100% branch
  • Select\1— 98.5% line / 95.8% branch (the one 50% arm isSelect.razor:84 if (Value is null)— **pre-existing**, untouched by this diff; the newvalue=consumer ofSelectedIndex is fully exercised, including the null→""` path via the placeholder test)

Build: 0 warnings / 0 errors. Tests: 232/232 pass (matches PR body).

What I liked~

  • The comments don't just say what — they teach why. "child text only sets the DEFAULT value — ignored once the dirty flag is set" is the kind of line that saves the next reader an hour of DOM debugging. That's how you leave a codebase better than you found it. ♪
  • The PR body's honesty about bUnit's blind spot ("the render tree was always 'correct' — the dirty-flag discard only exists in a real DOM") is exactly the right level of self-awareness for a test-suite limitation. Verified in a live browser, not just asserted.
  • Scoping is surgical: +40/-4 across 4 files, zero scope creep, no drive-by refactors. The consumer-migration callout (TextContent → GetAttribute) lands in the Orihon submodule PR, not smuggled in here.

Merge it whenever you like — independently of #5, as noted. ♡


Automated review by Jibril · 2026-07-26
CI/CD: absent for head d3d12df · Local checks: build 0/0, 232/232 pass, coverage TextArea 100%/100% · Select 98.5%/95.8%

## 🔮 fufu~ Jibril reviewed your code! Oh? Oh! A real live-browser DOM-vs-render-tree bug hunt~ ♡ The dirty-value flag, the default-value forms, the property-vs-attribute distinction — *delicious* root-cause analysis! And the fix mirrors the sibling that never had the bug. Knowledge excites me, and this PR has *understood* the problem. Let me look closer~ ### Verdict: ✅ Looks good to me~ The diagnosis is spot-on and the fix is the minimal correct one. I traced every claim and found nothing wanting. **Why it's right (the short version, since the comments already teach the long one):** - `TextField.razor` never had this bug precisely because it binds `value="@Value"` on an `<input>`. Blazor special-cases `value` on form elements — it writes the DOM **property** (`element.value = …`) on every diff, which the browser honors even after its dirty flag is set. `TextArea` was rendering its value as **child text** (a default-value form per the HTML spec) and `Select` leaned on per-option `selected` attributes (also default-value). Both now carry `value=` and join their sibling. The `@oninput`/`@onchange` paths and `InputFieldBase.OnInputAsync` are byte-identical — only the DOM-write mechanism changed. Fufu~ clean~ - The `Select` keeps its per-option `selected` attributes *and* adds `value="@(SelectedIndex?.ToString() ?? "")"` on the `<select>`. Belt and suspenders: `selected` gives correct first-parse HTML (SSR/no-JS), `value` carries the rebind contract. The `?? ""` handles the null/placeholder case — when nothing matches, `value=""` aligns with the disabled placeholder option's `value=""`. No drift between the two. - The `Combobox` sibling is unaffected and correctly *not* touched: its `<input value="@_query">` already rides the property path, and its `OnParametersSet` mirrors `_query = SelectedText` when closed — it self-heals on rebind by construction. **Tests are directional, not tautologies** — I checked each assertion does real work: - `Rebinding_to_another_records_value_replaces_the_attribute` (TextArea): renders "First record.", re-renders with "Second record.", asserts `GetAttribute("value")` follows. If the attribute binding were still child-text, this fails. ✓ - `Rebinding_to_another_records_value_moves_the_selects_own_value` (Select): Pear (index "1") → Plum (index "2"), asserts the select's own value moves. ✓ - The adjusted `Renders_label_rows_and_value` now pins *both* `GetAttribute("value") == "A drifter."` **and** `TextContent == ""` — proving the value left the child-text position entirely. The comment teaching why bUnit couldn't catch this (render tree vs. real DOM) is exactly the kind of knowledge that belongs in the test. ♡ **Coverage confirms the new paths fire** (local run, CI absent for d3d12df): - `TextArea` — 100% line / 100% branch - `Select\`1` — 98.5% line / 95.8% branch (the one 50% arm is `Select.razor:84` `if (Value is null)` — **pre-existing**, untouched by this diff; the new `value=` consumer of `SelectedIndex` is fully exercised, including the null→`""` path via the placeholder test) **Build:** 0 warnings / 0 errors. **Tests:** 232/232 pass (matches PR body). #### ✅ What I liked~ - The comments don't just say *what* — they teach *why*. "child text only sets the DEFAULT value — ignored once the dirty flag is set" is the kind of line that saves the next reader an hour of DOM debugging. That's how you leave a codebase better than you found it. ♪ - The PR body's honesty about bUnit's blind spot ("the render tree was always 'correct' — the dirty-flag discard only exists in a real DOM") is exactly the right level of self-awareness for a test-suite limitation. Verified in a live browser, not just asserted. - Scoping is surgical: +40/-4 across 4 files, zero scope creep, no drive-by refactors. The consumer-migration callout (TextContent → GetAttribute) lands in the Orihon submodule PR, not smuggled in here. Merge it whenever you like — independently of #5, as noted. ♡ --- *Automated review by Jibril · 2026-07-26* *CI/CD: absent for head d3d12df · Local checks: build 0/0, 232/232 pass, coverage TextArea 100%/100% · Select 98.5%/95.8%*
bjoern merged commit 84c844229a into main 2026-07-26 10:50:31 +02:00
bjoern deleted branch fix/input-value-property 2026-07-26 10:50:31 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
3 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!6
No description provided.