fix(replay): mask the full height of auto-growing text fields - #576
Merged
marandaneto merged 1 commit intoSep 11, 2026
Merged
Conversation
Member
|
makes sense thanks @lukas-roqqu |
marandaneto
approved these changes
Sep 11, 2026
The RenderEditable mask was sized from preferredLineHeight * (maxLines ?? 1), so a field with maxLines: null or expands: true got a one-line mask while its laid-out height grew with its content, and every line after the first was recorded unmasked. The mask height is now the larger of the field's size.height and the line-count estimate. The estimate is kept because a dense or constrained field can be laid out far shorter than the text it paints, so size.height alone would under-mask it.
marandaneto
force-pushed
the
fix/replay-multiline-editable-mask
branch
from
September 11, 2026 11:42
3bbb3ea to
7c7bbbc
Compare
marandaneto
enabled auto-merge (squash)
September 11, 2026 11:42
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.
💡 Motivation and Context
Session replay masks a text field's input by painting a black rectangle over its
RenderEditable.RenderEditableParsersized that rectangle aspreferredLineHeight * (maxLines ?? 1), deliberately ignoringsize.height, because a dense or constrained field (isDense: true,Expanded, ScreenUtil scaling) can be laid out far shorter than the text it paints — the file documents a 1.3px layout height against a 39px line.An auto-growing field has
maxLines: null(TextField(maxLines: null),TextFormField(maxLines: null),CupertinoTextField(maxLines: null), andexpands: true), so the estimate fell back to one line while the field's real height grew with its content. Every line after the first was recorded unmasked. "Notes", "address" and "message" fields are commonly built this way.Measured on
mainwith a 300px-wideTextField(maxLines: null)holding four lines (Material 3 default text style,preferredLineHeight24):RenderEditable.size.heightmainTextField(maxLines: null), 4 linesCupertinoTextField(maxLines: null), 4 linesTextField(maxLines: null, expands: true)in a 200px boxThe mask height is now the larger of the two bounds,
max(size.height, preferredLineHeight * (maxLines ?? 1)). The line-count estimate still protects the dense and constrained cases, andsize.heightextends the mask to the field's real height when the field is taller than the estimate. No mask gets smaller than it is today.mainThe two frames above are produced by the same three stages the SDK uses (
RepaintBoundary.toImage→getMaskElements→ImageMaskPainter) on a 300px-wide auto-growing field with four lines of content and an unmasked label below it. Onmainonly the first line is covered; with this PR the mask stops at the field's border and the label is untouched.On device
The example app ran on an iOS 26 simulator (iPhone 17) against a local HTTP server standing in for PostHog, which served a real project's remote config (
sessionRecordingenabled) and stored every/s/upload. Nothing was sent to PostHog. The frames below are the$snapshotpayloads the SDK actually shipped (402×874 WebP, decoded), withmaskAllTexts = trueand the new "Test 11b" case moved to the top of the masking tests screen for the run. Same build otherwise; onlyrender_editable_parser.dartdiffers.mainOn
mainthe shipped frame shows "Line two", "Line three" and "Line four" in the clear; with the fix the field is black to its border and every other mask on the screen is unchanged.Scope
RenderEditablemask only. Text, images and platform views are not touched.maxLines: Nand fewer lines of content is still masked toNlines, as before — this PR never shrinks a mask.make checkApiDartpasses against the checked-in snapshot).💚 How did you test it?
posthog_flutter/test/render_editable_mask_test.dart(new, 8 tests) pumps each field under the mask controller'sRepaintBoundary, reads the_Editablemask fromgetMaskElements(includeAllWidgets: true), maps it into container coordinates with the element's transform, and compares it with theRenderEditable's laid-out bounds.mainTextField(maxLines: null)is masked over its full heightTextField(expands: true)is masked over the box it fills (200pxSizedBox)TextFormField(maxLines: null)is masked over its full heightCupertinoTextField(maxLines: null)is masked over its full heightTextField(maxLines: 3)still masks three linesisDense: true) still masks at least one lineSizedBox) still masks one lineTests 5–7 are regression guards for the line-count estimate; 7 squeezes the
RenderEditableto 4px and checks the mask stayspreferredLineHeighttall, which is exactly whatsize.heighton its own would break. Test 8 captures the frame withRepaintBoundary.toImage, paints the masks withImageMaskPainter().drawMaskedImage, and asserts the pixel at the centre of line four is0xFF000000in the masked frame and the text colour in the unmasked one.Full suite: 348 tests pass (340 existing + 8 new).
The example app gains "Test 11b: Auto-growing TextField (maxLines: null)" on the masking tests screen, pre-filled with four lines, next to the existing three-line case.
Checks run from the repository root:
📝 Checklist
If releasing new changes
pnpm changeset🤖 Agent context
Autonomy: Human-driven (agent-assisted)
The work was directed by the PR author (@lukas-roqqu, DRI), who found the leak while auditing session replay masks in a fintech app, and reviewed the diff, the tests and the device frames. It was implemented with Claude Code (Claude Fable 5.1) using file tools, git and
flutter test. The one design decision was to keep the existing line-count estimate and take the larger of the two bounds rather than switch tosize.height, so the dense and constrained cases the parser already handles stay protected; test 7 pins that down.