From 7a7c025a0a4d8c67069e4037645290e965da78c4 Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:11:18 +0000 Subject: [PATCH 1/5] fix: compute replay mask rects in the same frame as the pixels Move both mask tree walks into the async capture body, right after `endOfFrame` and before `toImage()`, so mask rects and pixels come from the same frame. A walk done up front froze frame N positions and painted them onto frame N+k, leaking content when the UI moved. Also fail closed: when `maskAllTexts` or `maskAllImages` is on and the walk returns null, drop the frame instead of shipping an unmasked screenshot. Generated-By: PostHog Desktop Task-Id: ba9fae9d-1da9-428b-acb7-d5455b503992 --- .changeset/fix-stale-mask-rects.md | 5 +++ .../screenshot/screenshot_capturer.dart | 37 +++++++++++++------ 2 files changed, 31 insertions(+), 11 deletions(-) create mode 100644 .changeset/fix-stale-mask-rects.md diff --git a/.changeset/fix-stale-mask-rects.md b/.changeset/fix-stale-mask-rects.md new file mode 100644 index 00000000..3c16323b --- /dev/null +++ b/.changeset/fix-stale-mask-rects.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Fix a session replay leak where masks could miss moving content. The mask rects are now computed in the same frame as the captured pixels, and a failed mask walk drops the frame instead of shipping an unmasked screenshot. diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 46012b23..2ff5b1d6 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -455,16 +455,8 @@ class ScreenshotCapturer { ); final replayConfig = effectiveConfig.sessionReplayConfig; - - final postHogWidgetWrapperElements = - PostHogMaskController.instance.getPostHogWidgetWrapperElements(); - - // call getCurrentScreenRects if really necessary - List? elementsDataWidgets; - if (replayConfig.maskAllTexts || replayConfig.maskAllImages) { - elementsDataWidgets = - PostHogMaskController.instance.getCurrentWidgetsElements(); - } + final maskAllContent = + replayConfig.maskAllTexts || replayConfig.maskAllImages; ui.Image? image; ui.PictureRecorder? recorder; @@ -486,6 +478,29 @@ class ScreenshotCapturer { // wait the UI to settle await SchedulerBinding.instance.endOfFrame; + + // Walk the tree for mask rects here, with no await before toImage(), + // so the rects and the pixels come from the same frame. A walk done + // before the async body freezes frame N's positions and paints them + // onto frame N+k, which leaks content when the UI moves. + final postHogWidgetWrapperElements = + PostHogMaskController.instance.getPostHogWidgetWrapperElements(); + + List? elementsDataWidgets; + if (maskAllContent) { + elementsDataWidgets = + PostHogMaskController.instance.getCurrentWidgetsElements(); + // Fail closed: a failed walk must drop the frame, never ship an + // unmasked screenshot. + if (elementsDataWidgets == null) { + printIfDebug( + 'Masking is enabled but the widget walk failed, dropping the frame.', + ); + completer.complete(null); + return; + } + } + image = await renderObject.toImage(pixelRatio: pixelRatio); final currentImage = image; @@ -577,7 +592,7 @@ class ScreenshotCapturer { return; } - if (replayConfig.maskAllTexts || replayConfig.maskAllImages) { + if (maskAllContent) { if (elementsDataWidgets != null && elementsDataWidgets.isNotEmpty) { _imageMaskPainter.drawMaskedImage( canvas, From d7f101dc1b8c52adfc06c089749d6c9e79df29af Mon Sep 17 00:00:00 2001 From: "posthog[bot]" <206114724+posthog[bot]@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:27:23 +0000 Subject: [PATCH 2/5] fix(web): await captureException call inside its try block A newer analyzer version in CI flags `unawaited_return_in_try_block` at posthog_flutter_web.dart:430, which fails both the Dart analyze and Pub publish dry run required checks. Awaiting the call also lets the surrounding `on Exception catch` handle failures from `handleWebMethodCall`. Generated-By: PostHog Desktop Task-Id: ba9fae9d-1da9-428b-acb7-d5455b503992 --- posthog_flutter/lib/posthog_flutter_web.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index aafa69e6..c55ebdeb 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -427,7 +427,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { exceptionData.cast(), ); - return handleWebMethodCall( + return await handleWebMethodCall( MethodCall('captureException', {'properties': normalizedData}), ); } on Exception catch (exception) { From 92682eeba7c9cdafe5e985cc7629e350e703c174 Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 18 Aug 2026 15:48:32 +0200 Subject: [PATCH 3/5] fix(web): propagate exception capture failures --- posthog_flutter/lib/posthog_flutter_web.dart | 1 + posthog_flutter/test/posthog_flutter_web_handler_test.dart | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 50790dc0..8f2ab329 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -432,6 +432,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { ); } catch (error) { printIfDebug('Exception in captureException: $error'); + rethrow; } } diff --git a/posthog_flutter/test/posthog_flutter_web_handler_test.dart b/posthog_flutter/test/posthog_flutter_web_handler_test.dart index eb20652e..5297b889 100644 --- a/posthog_flutter/test/posthog_flutter_web_handler_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_handler_test.dart @@ -157,7 +157,7 @@ void main() { }); group('PosthogFlutterWeb captureException', () { - test('handles native JavaScript failures from posthog-js', () async { + test('propagates native JavaScript failures from posthog-js', () async { final failWithJavaScriptError = globalContext.callMethod( 'eval'.toJS, '() => { throw new Error("capture failed"); }'.toJS, @@ -170,7 +170,7 @@ void main() { await expectLater( PosthogFlutterWeb().captureException(error: StateError('boom')), - completes, + throwsA(anything), ); }); }); From c54492533281a9e34b7eb923c57a9e4fac9ffc4c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 18 Aug 2026 15:59:20 +0200 Subject: [PATCH 4/5] revert: propagate exception capture failures --- posthog_flutter/lib/posthog_flutter_web.dart | 1 - posthog_flutter/test/posthog_flutter_web_handler_test.dart | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 8f2ab329..50790dc0 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -432,7 +432,6 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { ); } catch (error) { printIfDebug('Exception in captureException: $error'); - rethrow; } } diff --git a/posthog_flutter/test/posthog_flutter_web_handler_test.dart b/posthog_flutter/test/posthog_flutter_web_handler_test.dart index 5297b889..eb20652e 100644 --- a/posthog_flutter/test/posthog_flutter_web_handler_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_handler_test.dart @@ -157,7 +157,7 @@ void main() { }); group('PosthogFlutterWeb captureException', () { - test('propagates native JavaScript failures from posthog-js', () async { + test('handles native JavaScript failures from posthog-js', () async { final failWithJavaScriptError = globalContext.callMethod( 'eval'.toJS, '() => { throw new Error("capture failed"); }'.toJS, @@ -170,7 +170,7 @@ void main() { await expectLater( PosthogFlutterWeb().captureException(error: StateError('boom')), - throwsA(anything), + completes, ); }); }); From 8c7e54bc6d9ea71892be3b4951898431de3c016c Mon Sep 17 00:00:00 2001 From: Manoel Aranda Neto Date: Tue, 18 Aug 2026 16:14:00 +0200 Subject: [PATCH 5/5] fix: walk replay mask tree once --- .../screenshot/screenshot_capturer.dart | 50 +++++++------------ 1 file changed, 17 insertions(+), 33 deletions(-) diff --git a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart index 2ff5b1d6..bd42f9e6 100644 --- a/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart +++ b/posthog_flutter/lib/src/replay/screenshot/screenshot_capturer.dart @@ -483,22 +483,17 @@ class ScreenshotCapturer { // so the rects and the pixels come from the same frame. A walk done // before the async body freezes frame N's positions and paints them // onto frame N+k, which leaks content when the UI moves. - final postHogWidgetWrapperElements = - PostHogMaskController.instance.getPostHogWidgetWrapperElements(); - - List? elementsDataWidgets; - if (maskAllContent) { - elementsDataWidgets = - PostHogMaskController.instance.getCurrentWidgetsElements(); - // Fail closed: a failed walk must drop the frame, never ship an - // unmasked screenshot. - if (elementsDataWidgets == null) { - printIfDebug( - 'Masking is enabled but the widget walk failed, dropping the frame.', - ); - completer.complete(null); - return; - } + final maskElements = PostHogMaskController.instance.getMaskElements( + includeAllWidgets: maskAllContent, + ); + // Fail closed: a failed walk must drop the frame, never ship an + // unmasked screenshot. + if (maskElements == null) { + printIfDebug( + 'The widget mask walk failed, dropping the frame.', + ); + completer.complete(null); + return; } image = await renderObject.toImage(pixelRatio: pixelRatio); @@ -592,23 +587,12 @@ class ScreenshotCapturer { return; } - if (maskAllContent) { - if (elementsDataWidgets != null && elementsDataWidgets.isNotEmpty) { - _imageMaskPainter.drawMaskedImage( - canvas, - elementsDataWidgets, - pixelRatio, - ); - } - } else { - if (postHogWidgetWrapperElements != null && - postHogWidgetWrapperElements.isNotEmpty) { - _imageMaskPainter.drawMaskedImage( - canvas, - postHogWidgetWrapperElements, - pixelRatio, - ); - } + if (maskElements.isNotEmpty) { + _imageMaskPainter.drawMaskedImage( + canvas, + maskElements, + pixelRatio, + ); } if (pvRects.masked.isNotEmpty) {