Skip to content

fix(replay): mask the full height of auto-growing text fields - #576

Merged
marandaneto merged 1 commit into
PostHog:mainfrom
lukas-roqqu:fix/replay-multiline-editable-mask
Sep 11, 2026
Merged

fix(replay): mask the full height of auto-growing text fields#576
marandaneto merged 1 commit into
PostHog:mainfrom
lukas-roqqu:fix/replay-multiline-editable-mask

Conversation

@lukas-roqqu

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Session replay masks a text field's input by painting a black rectangle over its RenderEditable. RenderEditableParser sized that rectangle as preferredLineHeight * (maxLines ?? 1), deliberately ignoring size.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), and expands: 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 main with a 300px-wide TextField(maxLines: null) holding four lines (Material 3 default text style, preferredLineHeight 24):

RenderEditable.size.height mask height on main mask height, this PR
TextField(maxLines: null), 4 lines 96 24 96
CupertinoTextField(maxLines: null), 4 lines 68 17 68
TextField(maxLines: null, expands: true) in a 200px box 200 24 200

The 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, and size.height extends the mask to the field's real height when the field is taller than the estimate. No mask gets smaller than it is today.

main this PR
only the first line masked the whole field masked

The two frames above are produced by the same three stages the SDK uses (RepaintBoundary.toImagegetMaskElementsImageMaskPainter) on a 300px-wide auto-growing field with four lines of content and an unmasked label below it. On main only 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 (sessionRecording enabled) and stored every /s/ upload. Nothing was sent to PostHog. The frames below are the $snapshot payloads the SDK actually shipped (402×874 WebP, decoded), with maskAllTexts = true and the new "Test 11b" case moved to the top of the masking tests screen for the run. Same build otherwise; only render_editable_parser.dart differs.

main this PR
shipped frame on main: lines two to four readable shipped frame with the fix: the whole field is black

On main the 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

  • The RenderEditable mask only. Text, images and platform views are not touched.
  • A field with maxLines: N and fewer lines of content is still masked to N lines, as before — this PR never shrinks a mask.
  • The public API is unchanged (make checkApiDart passes 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's RepaintBoundary, reads the _Editable mask from getMaskElements(includeAllWidgets: true), maps it into container coordinates with the element's transform, and compares it with the RenderEditable's laid-out bounds.

# test on main this PR
1 TextField(maxLines: null) is masked over its full height fails — expected 96.0, actual 24.0 passes
2 TextField(expands: true) is masked over the box it fills (200px SizedBox) fails passes
3 TextFormField(maxLines: null) is masked over its full height fails passes
4 CupertinoTextField(maxLines: null) is masked over its full height fails — expected 68.0, actual 17.0 passes
5 TextField(maxLines: 3) still masks three lines passes passes
6 a dense single-line field (isDense: true) still masks at least one line passes passes
7 a field laid out shorter than its text (4px SizedBox) still masks one line passes passes
8 the masked capture blacks out the fourth line of the field (pixel-level) fails — pixel is the text colour, not black passes

Tests 5–7 are regression guards for the line-count estimate; 7 squeezes the RenderEditable to 4px and checks the mask stays preferredLineHeight tall, which is exactly what size.height on its own would break. Test 8 captures the frame with RepaintBoundary.toImage, paints the masks with ImageMaskPainter().drawMaskedImage, and asserts the pixel at the centre of line four is 0xFF000000 in 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:

dart format --set-exit-if-changed ./   # Formatted 134 files (0 changed)
dart analyze .                          # No issues found!
make checkApiDart                       # posthog_flutter public API snapshot is up to date.
cd posthog_flutter && flutter test      # +348: All tests passed!

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed. — no user-facing API change; the parser's doc comment is updated
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Added a patch changeset file manually, equivalent to 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 to size.height, so the dense and constrained cases the parser already handles stay protected; test 7 pins that down.

@lukas-roqqu
lukas-roqqu requested a review from a team as a code owner September 11, 2026 11:18
@marandaneto

Copy link
Copy Markdown
Member

makes sense thanks @lukas-roqqu

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
marandaneto force-pushed the fix/replay-multiline-editable-mask branch from 3bbb3ea to 7c7bbbc Compare September 11, 2026 11:42
@marandaneto
marandaneto enabled auto-merge (squash) September 11, 2026 11:42
@marandaneto
marandaneto merged commit 3ce8592 into PostHog:main Sep 11, 2026
27 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants