[0.83] fix(textinput): center single-line text correctly at >100% display scale - #16302
Conversation
WindowsTextInputComponentView::GetContentSize() measured RichEdit content with a DPI of `pointScaleFactor * GetDpiForSystem()` and then divided the returned natural size by `pointScaleFactor`. TxGetNaturalSize actually reports in the device pixels of the measuring DC (GetDC(nullptr) -> screen DC at the system DPI, typically 96), which is independent of the per-monitor display scale in pointScaleFactor. As a result content height was under-measured by exactly the display scale at any scale > 100%, and calculateContentVerticalOffset() over-centered the text so it parked low and was bottom-clipped. Normalize both conversions by the DC's real DPI (GetDeviceCaps LOGPIXELS): extent uses DIP<->HIMETRIC at the fixed 96 DIPs/inch, and device px are converted back to DIPs with px*96/hdcDpi. Link Gdi32 explicitly for GetDeviceCaps. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
|
@microsoft-github-policy-service agree [company="Facilitron"] |
|
@microsoft-github-policy-service agree company="Facilitron" |
|
Update — honest validation results from a patched framework source build, including a finding reviewers should weigh. We compiled this fix into Microsoft.ReactNative from source (binary-gated: patched dll confirmed as the loaded module) and re-ran the probe. On THIS machine the raw input rendered pixel-identical before/after — for an instructive reason: the machine is DPI-degenerate for this bug (system DPI is 240, equal to the 2.5 pointScaleFactor, so the old More significant: programmatic ink measurement shows the rendered offset equals |
|
Azure Pipelines: There may be pipelines that require an authorized user to comment /azp run to run. |
There was a problem hiding this comment.
Pull request overview
This PR fixes single-line Fabric <TextInput> vertical centering/clipping at display scales >100% by correcting how RichEdit’s TxGetNaturalSize results (device pixels for the measuring DC) are converted back into DIPs.
Changes:
- Normalize
TxGetNaturalSizemeasurements using the measuring DC’s DPI (GetDeviceCaps(LOGPIXELSX/Y)) and a constant 96 DIPs/inch conversion for DIP↔HIMETRIC. - Link
Gdi32.libexplicitly to satisfyGetDeviceCapsusage in framework-source builds. - Add a change file to ship the fix as a patch.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp | Fixes DPI normalization in RichEdit content measurement used for single-line vertical centering; adds explicit Gdi32.lib link pragma. |
| change/react-native-windows-fix-textinput-centering-dpi.json | Declares the patch change for release notes/versioning. |
Suppressed comments (1)
vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp:1364
GetDeviceCaps(hdc, LOGPIXELSX/Y)can return 0 on failure; dividing byhdcDpiX/hdcDpiYwould then be a divide-by-zero. Even if this is unlikely for a screen DC, the guard is inexpensive and prevents a hard crash.
// naturalSize is in the DC's device pixels; convert device px -> DIPs using the
// DC's actual DPI (px * 96 / hdcDpi), not pointScaleFactor.
float contentWidth = static_cast<float>(naturalSize.cx) * DIPS_PER_INCH / hdcDpiX;
float contentHeight = static_cast<float>(naturalSize.cy) * DIPS_PER_INCH / hdcDpiY;
| SIZE extentHimetric = { | ||
| static_cast<LONG>(availableWidth * scale * HIMETRIC_PER_INCH / dpi), | ||
| static_cast<LONG>(std::numeric_limits<LONG>::max() * HIMETRIC_PER_INCH / dpi)}; | ||
| static_cast<LONG>(availableWidth * HIMETRIC_PER_INCH / DIPS_PER_INCH), | ||
| static_cast<LONG>(std::numeric_limits<LONG>::max() * HIMETRIC_PER_INCH / DIPS_PER_INCH)}; |
| // calculateContentVerticalOffset() over-centered the text (parked low / | ||
| // bottom-clipped). Normalize both conversions by the DC's real DPI instead. DIPs | ||
| // are 1/96in by definition, so the DIP<->HIMETRIC leg always uses 96. | ||
| const int hdcDpiX = GetDeviceCaps(hdc, LOGPIXELSX); |
There was a problem hiding this comment.
Can we work out some way to do this without loading gdi32.dll. We really dont want to be taking the performance costs of loading additional dlls. And we shouldn't require gdi for RNW.
…onversion Two review items from acoates-ms and the automated reviewer. 1. No Gdi32. GetDeviceCaps is replaced by GetDpiForSystem(), which is exported from user32 and is already called elsewhere in this file, so the fix no longer pulls an additional DLL into the process. For a GetDC(nullptr) screen DC the two report the same thing - the system DPI for the caller's DPI-awareness context - so the correction is unchanged, only its source. Windows reports square logical DPI for the screen (LOGPIXELSX == LOGPIXELSY), so a single value covers both axes and the #pragma comment(lib, "Gdi32.lib") is gone. 2. Out-of-range floating-point to integer conversion. The unbounded height constraint computed LONG_MAX * 2540 / 96 (~5.7e10) and cast it back to LONG, which is undefined behavior. LONG_MAX already means "unbounded" to RichEdit, so it is now passed directly with no arithmetic.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (1)
vnext/Microsoft.ReactNative/Fabric/Composition/TextInput/WindowsTextInputComponentView.cpp:1335
- The implementation now contradicts the PR's stated fix and validation: the description says this reads
GetDeviceCaps(hdc, LOGPIXELS*), linksGdi32.lib, and no longer usesGetDpiForSystem(), while this line does the opposite. Please either update the description/validation to document and substantiate theGetDpiForSystem()equivalence used here, or restore the described DC-specific query; otherwise reviewers cannot tell which implementation was actually built and tested.
const UINT hdcDpi = GetDpiForSystem();
|
Thanks — both addressed in the latest push. No Gdi32. Out-of-range conversion. Good catch from the automated reviewer, and it was a real defect rather than a style point: the unbounded height constraint computed One note in case it matters for how you'd like this to land: the fix is deliberately confined to |
|
/azp run |
|
Azure Pipelines: Successfully started running 1 pipeline(s). |

Problem
On a single-line
<TextInput>at any display scale > 100% (125%, 250%, …), the text and caret sit too low in the control and are clipped at the bottom.Root cause
WindowsTextInputComponentView::GetContentSize()measures RichEdit content viaTxGetNaturalSize, which returns sizes in the device pixels of the measuring DC. The DC comes fromGetDC(nullptr)— a screen DC whose logical DPI (GetDeviceCaps(hdc, LOGPIXELS)) is the system DPI (typically 96), unrelated to the per-monitor scale inpointScaleFactor. The code conflated the two: it built the HIMETRIC extent withdpi = pointScaleFactor * GetDpiForSystem()and divided the returned natural size bypointScaleFactor. Net: measured content height is short by exactly the display scale, socalculateContentVerticalOffset()centers with an undersizedcontentHeightand pushes the text down and off the bottom.Fix
Normalize both conversions by the DC's real DPI: DIP↔HIMETRIC always uses 96 (DIPs are 1/96in by definition); device px → DIP uses
px * 96 / GetDeviceCaps(hdc, LOGPIXELS).pointScaleFactor/GetDpiForSystem()are no longer used in the measurement.Gdi32.libis linked explicitly (#pragma comment) —GetDeviceCapsis a Gdi32 export and Microsoft.ReactNative does not otherwise reference it (LNK2019 without it in a framework source build).Validation
Bug reproduced in a production RNW 0.83.2 new-arch app (Facilitron FIT — an Expo monorepo app shipping a full Windows target) on Windows 11 ARM64, Debug, physical panel at a true 250% display scale. A stock single-line TextInput (deep
Libraries/Components/TextInput/TextInputimport, 44-DIP box, fontSize 18) parks its text on the bottom border and clips, while the app-side workaround this patch obsoletes (bottom paddingfontSize*2.3*(1-1/scale)) centers the same input:This diff has been compiled into Microsoft.ReactNative from source on this machine (yarn-patch framework source build; the changed measurement verified present in the built binary — and the
Gdi32.libpragma requirement was found that way). Remaining for upstream: CI build + pixel-level after-shots at 100/125/150/250%.Caveats for reviewers: base commit
8e869d7is itself "[0.83] fix text input scaling (#16291)" — please confirm this refines (does not regress) that change. Authored against0.83-stableto match our production app; happy to re-cut ontomainif preferred. Separately noted (not addressed here):pointScaleFactorcan go stale across monitor moves (no WM_DPICHANGED refresh).Microsoft Reviewers: Open in CodeFlow