Repository navigation
[Text input] Measure the caret outside the text layout getter - #22176
Merged
Merged
Conversation
…nvalidates layout TextPresenter's TextLayout getter runs UpdateCaret after creating the layout, and UpdateCaret raises CaretBoundsChanged. A subscriber that synchronously invalidates the text layout - the IME candidate window or any caret-following code forcing a layout pass does this in practice - nulls the field before the getter returns it, so the surrounding computation (the preedit update, or the measure pass materializing the layout) dereferences null. Found live by TextInputDebugger on its first Japanese IME session: the trace viewer's scroll-into-view pumped a layout pass from inside the caret callback and every composition update crashed.
|
You can test this PR using the following package version. |
MrJul
requested changes
Sep 9, 2026
Gillibald
force-pushed
the
stx/0-preedit-reentrancy
branch
from
September 9, 2026 14:40
b24c23a to
1d92065
Compare
The getter built the layout and then ran UpdateCaret, which raises CaretBoundsChanged. A handler that invalidates the layout - an IME candidate window, or caret-following code forcing a layout pass - disposed the layout the getter was about to return, so the getter handed back a field the handler had already cleared and the caller dereferenced null. - The getter builds the layout, marks the caret dirty and returns. It raises no events, so nothing invalidates the layout while it is being built. - EnsureCaretBounds measures the caret against the layout field rather than the property, so it can neither build a layout nor recurse. A handler invalidating from there only schedules the next measure pass. - The measure pass builds the layout once and settles the caret against it, so a preedit key press builds one layout where it built three. - A preedit change records the caret position it wants and lets that pass place it, normalized through the line's caret hit walkers so a position on the seam of a fallback-font run resolves to a hit the layout can measure. - The declaration of the layout field now names the five members that own its lifetime. Co-Authored-By: Claude Opus 5
HideCaret disposed the text layout and reshaped the text on the next access, which is work the caret does not need: it blinks over the text rather than taking part in it. Anything holding the layout across the call also lost it. Co-Authored-By: Claude Opus 5
Gillibald
force-pushed
the
stx/0-preedit-reentrancy
branch
from
September 9, 2026 14:52
1d92065 to
743cb64
Compare
|
You can test this PR using the following package version. |
MrJul
pushed a commit
to MrJul/Avalonia
that referenced
this pull request
Sep 22, 2026
…iaUI#22176) * Add failing test: preedit crashes when a CaretBoundsChanged handler invalidates layout TextPresenter's TextLayout getter runs UpdateCaret after creating the layout, and UpdateCaret raises CaretBoundsChanged. A subscriber that synchronously invalidates the text layout - the IME candidate window or any caret-following code forcing a layout pass does this in practice - nulls the field before the getter returns it, so the surrounding computation (the preedit update, or the measure pass materializing the layout) dereferences null. Found live by TextInputDebugger on its first Japanese IME session: the trace viewer's scroll-into-view pumped a layout pass from inside the caret callback and every composition update crashed. * Measure the caret outside the text layout getter The getter built the layout and then ran UpdateCaret, which raises CaretBoundsChanged. A handler that invalidates the layout - an IME candidate window, or caret-following code forcing a layout pass - disposed the layout the getter was about to return, so the getter handed back a field the handler had already cleared and the caller dereferenced null. - The getter builds the layout, marks the caret dirty and returns. It raises no events, so nothing invalidates the layout while it is being built. - EnsureCaretBounds measures the caret against the layout field rather than the property, so it can neither build a layout nor recurse. A handler invalidating from there only schedules the next measure pass. - The measure pass builds the layout once and settles the caret against it, so a preedit key press builds one layout where it built three. - A preedit change records the caret position it wants and lets that pass place it, normalized through the line's caret hit walkers so a position on the seam of a fallback-font run resolves to a hit the layout can measure. - The declaration of the layout field now names the five members that own its lifetime. Co-Authored-By: Claude Opus 5* Repaint instead of rebuilding the layout to hide the caret HideCaret disposed the text layout and reshaped the text on the next access, which is work the caret does not need: it blinks over the text rather than taking part in it. Anything holding the layout across the call also lost it. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5
1 of 3 tasks
grokys
pushed a commit
to Evan260/Avalonia
that referenced
this pull request
Oct 7, 2026
…iaUI#22176) * Add failing test: preedit crashes when a CaretBoundsChanged handler invalidates layout TextPresenter's TextLayout getter runs UpdateCaret after creating the layout, and UpdateCaret raises CaretBoundsChanged. A subscriber that synchronously invalidates the text layout - the IME candidate window or any caret-following code forcing a layout pass does this in practice - nulls the field before the getter returns it, so the surrounding computation (the preedit update, or the measure pass materializing the layout) dereferences null. Found live by TextInputDebugger on its first Japanese IME session: the trace viewer's scroll-into-view pumped a layout pass from inside the caret callback and every composition update crashed. * Measure the caret outside the text layout getter The getter built the layout and then ran UpdateCaret, which raises CaretBoundsChanged. A handler that invalidates the layout - an IME candidate window, or caret-following code forcing a layout pass - disposed the layout the getter was about to return, so the getter handed back a field the handler had already cleared and the caller dereferenced null. - The getter builds the layout, marks the caret dirty and returns. It raises no events, so nothing invalidates the layout while it is being built. - EnsureCaretBounds measures the caret against the layout field rather than the property, so it can neither build a layout nor recurse. A handler invalidating from there only schedules the next measure pass. - The measure pass builds the layout once and settles the caret against it, so a preedit key press builds one layout where it built three. - A preedit change records the caret position it wants and lets that pass place it, normalized through the line's caret hit walkers so a position on the seam of a fallback-font run resolves to a hit the layout can measure. - The declaration of the layout field now names the five members that own its lifetime. Co-Authored-By: Claude Opus 5* Repaint instead of rebuilding the layout to hide the caret HideCaret disposed the text layout and reshaped the text on the next access, which is work the caret does not need: it blinks over the text rather than taking part in it. Anything holding the layout across the call also lost it. Co-Authored-By: Claude Opus 5 --------- Co-authored-by: Claude Opus 5
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What does the pull request do?
Part of the structured text input work tracked in #22168.
Takes the caret measurement out of the
TextPresenter.TextLayoutgetter, so building a layout raises no events and a preedit key press builds one layout instead of three. Hiding the caret stops rebuilding the layout as well.What is the current behavior?
The getter builds the layout and then runs
UpdateCaret, which raisesCaretBoundsChanged. A handler that invalidates the layout - an IME candidate window, or caret-following code that forces a layout pass - disposes the layout the getter is about to return, and the getter hands back a field the handler has already cleared. The caller then dereferences null:MeasureOverridethrows on the first preedit update while such a handler is attached.The same path is wasteful. Counting
CreateTextLayoutcalls per preedit key press today gives three: one fromOnPreeditChanged, which thePreeditTextcase of the invalidation switch disposes immediately after; one from the cursor-position property that follows it; and one from the measure pass that actually renders.OnPreeditChangedalso builds a rawCharacterHitatCaretIndex + cursorand measures it against whatever layout exists, which is either one that does not contain the preedit yet or a position on the seam of the preedit's fallback-font run. Both are outside what hit-testing accepts.What is the updated/expected behavior with this PR?
The getter builds the layout, marks the caret dirty, and returns, raising nothing.
EnsureCaretBoundsmeasures the caret afterwards against the layout field rather than the property, so it can neither build a layout nor recurse; a handler that invalidates from there only schedules the next measure pass. The measure pass builds the layout once and settles the caret against that build, and a preedit change records the position it wants for that pass to place, normalized through the line's caret hit walkers.Checklist
Breaking changes
None.
CaretBoundsChangednow fires from the measure pass or from an explicit caret move rather than from an arbitrary layout access, andHideCaretno longer invalidates the layout. Both are internal timing changes in the presenter.Obsoletions / Deprecations
None.
Fixed issues
Part of #22168
🤖 Generated with Claude Code