From fc3a1167a0fdf69d37e34601b3e7fb5d6125675d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Mon, 27 Jul 2026 21:49:49 +0300 Subject: [PATCH 01/12] feat(replay): PostHogMaskWidget enables web canvas masking on its own MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Canvas masking only registered with posthog-js when the app declared session_recording.captureCanvas.canvasMaskRegionsFn in its posthog.init call, so a developer who wrapped sensitive UI in PostHogMaskWidget and never touched web/index.html got no masking at all, silently — while the same widget needs no setup on iOS and Android. The first PostHogMaskWidget to mount now opts the app in: the mount is routed through a conditional import (no-op off web) to WebCanvasMaskProvider, which registers the mask-region provider if it has not already. Registration stays idempotent, so any number of mask widgets produce at most one set_config and one recording restart. The gate is kept for everyone else: apps with neither a PostHogMaskWidget nor the init declaration still see no set_config, no blockSelector and no restart. Registering mid-session restarts an in-flight recording (posthog-js reads canvas capture options only when recording starts) and does not cover frames captured before the first mount — declaring canvasMaskRegionsFn in posthog.init remains the only way to cover the pre-boot window. Also drops a stale caveat from the example app: test 15's title claimed PostHogMaskWidget with multiple children needs maskAllTexts or maskAllImages. It does not on either platform — getMaskElements calls extractMaskWidgetRects() unconditionally on web, and screenshot_capturer calls getPostHogWidgetWrapperElements() outside the flags branch on mobile. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HPiKky15SKqNg9PT2wHeaw --- .changeset/mask-widget-web-canvas.md | 24 ++++ example/lib/masking_tests_screen.dart | 2 +- .../mask/canvas_mask_registration_io.dart | 3 + .../mask/canvas_mask_registration_web.dart | 16 +++ .../src/replay/mask/posthog_mask_widget.dart | 18 ++- .../replay/web/web_canvas_mask_provider.dart | 105 ++++++++++++++---- .../test/web_canvas_mask_provider_test.dart | 84 +++++++++++++- 7 files changed, 225 insertions(+), 27 deletions(-) create mode 100644 .changeset/mask-widget-web-canvas.md create mode 100644 posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart create mode 100644 posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart diff --git a/.changeset/mask-widget-web-canvas.md b/.changeset/mask-widget-web-canvas.md new file mode 100644 index 00000000..b5804265 --- /dev/null +++ b/.changeset/mask-widget-web-canvas.md @@ -0,0 +1,24 @@ +--- +"posthog_flutter": minor +--- + +**`PostHogMaskWidget` now works on Flutter web without any HTML setup.** + +Wrapping a widget in `PostHogMaskWidget` is the most explicit way to say "never +record this", so on web the first one to mount now switches canvas masking on by +itself — no `canvasMaskRegionsFn` in `posthog.init` required. `PostHogMaskWidget` +therefore behaves the same on web as it does on iOS and Android. + +Three things to know: +- Switching masking on restarts an in-flight recording once, because masking also + excludes the Flutter semantics DOM tree via `blockSelector`, which posthog-js only + reads when recording starts. You will see the recording split at that point. +- Frames captured before the first `PostHogMaskWidget` mounts are recorded unmasked. + Declaring `canvasMaskRegionsFn: () => null` in `posthog.init` is still the only + way to cover the window between page load and Flutter booting, and it moves the + restart to Flutter boot rather than to whenever your first `PostHogMaskWidget` + mounts. +- Your app must be wrapped in `PostHogWidget`. If it is not, canvas frames are + skipped instead of recorded unmasked, and a console warning explains the fix. + +Apps that declare neither are untouched, exactly as before. diff --git a/example/lib/masking_tests_screen.dart b/example/lib/masking_tests_screen.dart index dd1e3ffa..12a50855 100644 --- a/example/lib/masking_tests_screen.dart +++ b/example/lib/masking_tests_screen.dart @@ -256,7 +256,7 @@ class _MaskingTestsScreenState extends State { // The amber box matches no masking rule on its own, so it is // covered only if the wrapper contributes its own rect. _buildTestSection( - 'Test 15: PostHogMaskWidget with multiple children (needs maskAllTexts or maskAllImages)', + 'Test 15: PostHogMaskWidget with multiple children', PostHogMaskWidget( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart new file mode 100644 index 00000000..d77f82dd --- /dev/null +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart @@ -0,0 +1,3 @@ +/// Canvas masking is a Flutter web concern; on every other platform +/// `PostHogMaskWidget` is honored by the native screenshot pipeline instead. +void notifyMaskWidgetMounted() {} diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart new file mode 100644 index 00000000..b58fa928 --- /dev/null +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart @@ -0,0 +1,16 @@ +import 'package:flutter/scheduler.dart'; + +import '../web/web_canvas_mask_provider.dart'; + +/// A mounted `PostHogMaskWidget` is an explicit request for masking, so it +/// opts the app into canvas masking even when `posthog.init` never declared +/// `canvasMaskRegionsFn`. +/// +/// Deferred to the end of the frame because `initState` runs during Flutter's +/// build phase: registering calls straight into posthog-js and restarts an +/// in-flight recording. +void notifyMaskWidgetMounted() { + SchedulerBinding.instance.addPostFrameCallback((_) { + WebCanvasMaskProvider.notifyMaskWidgetMounted(); + }); +} diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index 95aaf5de..06092306 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -1,14 +1,25 @@ import 'package:flutter/material.dart'; +import 'canvas_mask_registration_io.dart' + if (dart.library.js_interop) 'canvas_mask_registration_web.dart'; + /// Masks a widget subtree in PostHog session replay snapshots. /// /// Wrap sensitive UI with [PostHogMaskWidget] to hide that area in captured /// screenshots, regardless of the global session replay masking settings. /// -/// **Flutter web:** this has no effect unless canvas masking is enabled, since -/// the canvas is masked by posthog-js rather than by this plugin. Declare +/// **Flutter web:** the canvas is masked by posthog-js rather than by this +/// plugin, so the first [PostHogMaskWidget] to mount turns canvas masking on — +/// which restarts an in-flight recording once, because masking also excludes +/// the Flutter semantics DOM tree via `blockSelector`, and posthog-js only +/// reads that when recording starts. Frames captured before that first +/// mount are recorded unmasked; to cover the window between `posthog.init` and +/// Flutter booting, declare /// `session_recording: { captureCanvas: { canvasMaskRegionsFn: () => null } }` -/// in your `posthog.init` call to turn it on. iOS and Android need no setup. +/// in your `posthog.init` call — until this plugin takes over, those frames are +/// skipped instead of recorded. Your app must be wrapped in `PostHogWidget`, +/// or canvas frames are skipped instead of recorded unmasked. iOS and Android +/// need no setup either way. class PostHogMaskWidget extends StatefulWidget { /// The widget subtree to mask in session replay snapshots. final Widget child; @@ -27,6 +38,7 @@ class PostHogMaskWidgetState extends State { @override void initState() { super.initState(); + notifyMaskWidgetMounted(); } @override diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 04984d19..1f2a7448 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:web/web.dart' as web; @@ -26,27 +27,66 @@ external JSObject _objectAssign(JSObject target, JSObject source); const _semanticsBlockSelector = 'flt-semantics-host'; +enum _ApplyResult { + applied, + + /// posthog-js is up but the app has not opted into canvas masking. + notOptedIn, + posthogNotReady, +} + /// Supplies widget-tree mask rectangles to posthog-js canvas recording via /// `session_recording.captureCanvas.canvasMaskRegionsFn`, so text painted /// into the CanvasKit canvas can be masked even though DOM masking cannot /// see it. /// -/// An app opts in by declaring `canvasMaskRegionsFn` in its `posthog.init` -/// call; declaring it as `() => null` also covers the frames captured before -/// this provider takes over. Without that key nothing is registered, so -/// recording is left exactly as posthog-js configured it. +/// An app opts in either by declaring `canvasMaskRegionsFn` in its +/// `posthog.init` call — declaring it as `() => null` also covers the frames +/// captured before this provider takes over — or by mounting a +/// `PostHogMaskWidget`, which registers the provider on first mount. Without +/// either, nothing is registered and recording is left exactly as posthog-js +/// configured it. /// /// Fails closed: a failed widget-tree walk returns null, which makes /// posthog-js skip the frame instead of shipping it unmasked. class WebCanvasMaskProvider { WebCanvasMaskProvider(this._config); + static WebCanvasMaskProvider? _active; + static bool _maskWidgetSeen = false; + + /// Opts the app into canvas masking because a `PostHogMaskWidget` mounted. + /// + /// Called from shared widget code through a conditional import, so it must + /// stay safe to call any number of times; a mount that happens before + /// [register] is remembered and applied when [register] runs. + static void notifyMaskWidgetMounted() { + if (_maskWidgetSeen) { + return; + } + _maskWidgetSeen = true; + try { + _active?._onMaskWidgetMounted(); + } catch (e) { + printIfDebug('PostHog: error enabling web canvas masking: $e'); + } + } + + @visibleForTesting + static void resetForTesting() { + _active = null; + _maskWidgetSeen = false; + } + final PostHogConfig _config; List? _cachedContainerRects; int _cachedAtFrame = -1; int _consecutiveWalkFailures = 0; bool _warnedWalkFailure = false; + bool _applied = false; + bool _maskWidgetMounted = false; + bool _polling = false; // shared so a second provider's cache is not compared against a counter that // never advances; the callback cannot be removed once added @@ -55,15 +95,27 @@ class WebCanvasMaskProvider { void register() { try { - _registerUnsafe(); + _active = this; + _maskWidgetMounted = _maskWidgetSeen; + _pump(); } catch (e) { printIfDebug('PostHog: failed to register web canvas masking: $e'); } } - void _registerUnsafe() { - final ph = posthog; - if (ph != null && _tryApplyConfig(ph)) { + void _onMaskWidgetMounted() { + if (_maskWidgetMounted) { + return; + } + _maskWidgetMounted = true; + // a retry chain still in flight picks the flag up on its next tick + if (!_applied && !_polling) { + _pump(); + } + } + + void _pump() { + if (_apply() != _ApplyResult.posthogNotReady) { return; } // the posthog-js snippet installs a config-less stub and array.js later @@ -74,11 +126,13 @@ class WebCanvasMaskProvider { 'PostHog: posthog-js not fully loaded yet, ' 'retrying canvas mask registration.', ); + _polling = true; _scheduleRetry(const Duration(milliseconds: 250), Duration.zero); } void _scheduleRetry(Duration delay, Duration elapsed) { if (elapsed >= const Duration(minutes: 2)) { + _polling = false; printIfDebug( 'PostHog: posthog-js did not become available, ' 'web canvas masking disabled.', @@ -87,8 +141,8 @@ class WebCanvasMaskProvider { } Timer(delay, () { try { - final current = posthog; - if (current != null && _tryApplyConfig(current)) { + if (_apply() != _ApplyResult.posthogNotReady) { + _polling = false; return; } final doubled = delay * 2; @@ -97,6 +151,7 @@ class WebCanvasMaskProvider { : doubled; _scheduleRetry(next, elapsed + delay); } catch (e) { + _polling = false; printIfDebug('PostHog: web canvas masking retry failed: $e'); } }); @@ -112,9 +167,13 @@ class WebCanvasMaskProvider { }); } - bool _tryApplyConfig(PostHog ph) { - if (ph.config == null) { - return false; + _ApplyResult _apply() { + if (_applied) { + return _ApplyResult.applied; + } + final ph = posthog; + if (ph == null || ph.config == null) { + return _ApplyResult.posthogNotReady; } // shallow-merge on top of any user-provided session_recording config — @@ -132,11 +191,12 @@ class WebCanvasMaskProvider { _objectAssign(captureCanvas, existingCaptureCanvas as JSObject); } // the app opts into canvas masking by declaring canvasMaskRegionsFn in - // posthog.init — registering regardless would restart an in-flight - // recording and drop the semantics tree for apps that never asked - if (!captureCanvas.has('canvasMaskRegionsFn')) { + // posthog.init or by mounting a PostHogMaskWidget — registering regardless + // would restart an in-flight recording and drop the semantics tree for + // apps that never asked + if (!captureCanvas.has('canvasMaskRegionsFn') && !_maskWidgetMounted) { _warnNotOptedIn(captureCanvas); - return true; + return _ApplyResult.notOptedIn; } _ensureFrameCounter(); captureCanvas.setProperty( @@ -150,14 +210,17 @@ class WebCanvasMaskProvider { final config = JSObject(); config.setProperty('session_recording'.toJS, sessionRecording); ph.set_config(config); + _applied = true; // blockSelector is only read when rrweb's record() starts, so an in-flight - // recording must be restarted + // recording must be restarted — worth it even with canvas capture off, + // since the semantics exclusion applies to plain DOM recording too (and + // recordCanvas can arrive from remote config after init anyway) if (ph.sessionRecordingStarted()) { ph.stopSessionRecording(); ph.startSessionRecording(); } - return true; + return _ApplyResult.applied; } // canvas recording can also be switched on from project settings, which the @@ -288,9 +351,7 @@ class WebCanvasMaskProvider { web.console.warn( 'PostHog: session replay masking cannot find the PostHogWidget ' 'widget tree, so canvas frames are not being recorded. ' - 'Wrap your app in PostHogWidget, or remove canvasMaskRegionsFn ' - 'from your posthog.init to disable canvas masking (the canvas ' - 'is then recorded unmasked).' + 'Wrap your app in PostHogWidget.' .toJS, ); } diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index aba31bd6..9d6eb7f3 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -4,7 +4,7 @@ library; import 'dart:js_interop'; import 'dart:js_interop_unsafe'; -import 'package:flutter/widgets.dart'; +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:posthog_flutter/src/replay/web/web_canvas_mask_provider.dart'; @@ -14,6 +14,7 @@ void main() { TestWidgetsFlutterBinding.ensureInitialized(); JSObject? capturedConfig; + var setConfigCalls = 0; var stopRecordingCalls = 0; var startRecordingCalls = 0; @@ -24,6 +25,7 @@ void main() { bool declaresMaskProvider = true, }) { capturedConfig = null; + setConfigCalls = 0; stopRecordingCalls = 0; startRecordingCalls = 0; final stub = JSObject(); @@ -47,6 +49,7 @@ void main() { 'set_config'.toJS, ((JSObject cfg) { capturedConfig = cfg; + setConfigCalls++; }).toJS, ); stub.setProperty( @@ -69,6 +72,8 @@ void main() { return stub; } + setUp(WebCanvasMaskProvider.resetForTesting); + tearDown(() { web.window.setProperty('posthog'.toJS, null); }); @@ -93,6 +98,83 @@ void main() { expect(startRecordingCalls, 0); }); + testWidgets('opts in when a PostHogMaskWidget mounted before register()', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(setConfigCalls, 0); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets('a mounted PostHogMaskWidget opts the app in on its own', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(capturedConfig, isNull); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + + final sessionRecording = capturedSessionRecording(); + expect( + sessionRecording + .getProperty('captureCanvas'.toJS) + .getProperty('canvasMaskRegionsFn'.toJS) + .isA(), + isTrue, + ); + expect( + sessionRecording.getProperty('blockSelector'.toJS).dartify(), + 'flt-semantics-host', + ); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets('registers once however many PostHogMaskWidgets mount', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + Widget maskWidgets(int count) => Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: List.generate( + count, + (_) => const PostHogMaskWidget(child: SizedBox.shrink()), + ), + ), + ); + await tester.pumpWidget(maskWidgets(3)); + await tester.pumpWidget(maskWidgets(5)); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets('opts in once posthog-js arrives after the widget mounted', + (tester) async { + web.window.setProperty('posthog'.toJS, null); + capturedConfig = null; + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(capturedConfig, isNull); + + installPosthogStub(declaresMaskProvider: false); + await tester.pump(const Duration(milliseconds: 300)); + + expect(setConfigCalls, 1); + }); + test('registers the mask provider via set_config', () { installPosthogStub(); From 4e356678da1039291dca78a27806f36e2445c683 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 28 Jul 2026 17:16:42 +0300 Subject: [PATCH 02/12] =?UTF-8?q?fix(replay):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20survive=20a=20failed=20mount-triggered=20apply,=20d?= =?UTF-8?q?ocument=20both=20opt-in=20paths?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A throw while applying the mount opt-in now schedules a retry chain instead of permanently consuming the opt-in. The not-opted-in warnings mention mounting a PostHogMaskWidget as the easier fix, both changesets describe the two opt-in paths (and that one mask widget enables the whole masking config, maskAllTexts/maskAllImages included), and tests cover cross-path idempotency plus the failed-mount retry. Co-Authored-By: Claude Fable 5 --- .changeset/canvas-masking-web.md | 14 ++++--- .changeset/mask-widget-web-canvas.md | 6 ++- .../replay/web/web_canvas_mask_provider.dart | 23 ++++++++--- .../test/web_canvas_mask_provider_test.dart | 41 +++++++++++++++++++ 4 files changed, 71 insertions(+), 13 deletions(-) diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index b41d9a09..ad96430e 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -31,12 +31,14 @@ it either. Notes: -- If you leave `canvasMaskRegionsFn` out, the plugin changes nothing and recording - behaves exactly as posthog-js is configured — as it did before this release. - **This includes `PostHogMaskWidget`**: on web it has no effect unless - `canvasMaskRegionsFn` is declared, because the canvas is masked by posthog-js and - the plugin only supplies rectangles to it once you have opted in. On iOS and - Android `PostHogMaskWidget` continues to work with no extra setup. +- Declaring `canvasMaskRegionsFn` is one of two ways to opt in: mounting a + `PostHogMaskWidget` also switches canvas masking on, with no HTML setup. + Declaring the key in `posthog.init` is still the only way to cover the frames + captured before Flutter boots, and it moves the restart of an in-flight + recording (opting in restarts it once so the new config applies) to Flutter + boot instead of first mount. If you do neither, the plugin changes nothing and + recording behaves exactly as posthog-js is configured — as it did before this + release. If canvas recording is enabled in your project settings rather than in `posthog.init`, the plugin cannot detect it and will not warn. - Web replay is configured entirely in `posthog.init`. The `config.sessionReplay` diff --git a/.changeset/mask-widget-web-canvas.md b/.changeset/mask-widget-web-canvas.md index b5804265..2afa27e5 100644 --- a/.changeset/mask-widget-web-canvas.md +++ b/.changeset/mask-widget-web-canvas.md @@ -9,7 +9,11 @@ record this", so on web the first one to mount now switches canvas masking on by itself — no `canvasMaskRegionsFn` in `posthog.init` required. `PostHogMaskWidget` therefore behaves the same on web as it does on iOS and Android. -Three things to know: +Things to know: +- Mounting one `PostHogMaskWidget` enables your whole masking configuration, not + just the wrapped subtree: `maskAllTexts` and `maskAllImages` default to true, so + a single mask widget turns on full text and image canvas masking — the same + semantics as mounting one on iOS and Android. - Switching masking on restarts an in-flight recording once, because masking also excludes the Flutter semantics DOM tree via `blockSelector`, which posthog-js only reads when recording starts. You will see the recording split at that point. diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 062d6478..4615334d 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -111,8 +111,17 @@ class WebCanvasMaskProvider { } _maskWidgetMounted = true; // a retry chain still in flight picks the flag up on its next tick - if (!_applied && !_polling) { + if (_applied || _polling) { + return; + } + try { _pump(); + } catch (e) { + printIfDebug('PostHog: error enabling web canvas masking: $e'); + // the opt-in is latched, so keep a chain alive to apply it later — + // otherwise a failed first apply would consume the opt-in for good + _polling = true; + _scheduleRetry(const Duration(milliseconds: 250)); } } @@ -236,8 +245,8 @@ class WebCanvasMaskProvider { // plugin cannot read — so only the posthog.init half of the leak is warnable void _warnNotOptedIn(JSObject captureCanvas) { printIfDebug( - 'PostHog: canvasMaskRegionsFn is not declared in posthog.init, ' - 'so Flutter web canvas masking is off.', + 'PostHog: Flutter web canvas masking is off — mount a PostHogMaskWidget ' + 'or declare canvasMaskRegionsFn in posthog.init to enable it.', ); final replayConfig = _config.sessionReplayConfig; if (!replayConfig.maskAllTexts && !replayConfig.maskAllImages) { @@ -246,9 +255,11 @@ class WebCanvasMaskProvider { final recordCanvas = captureCanvas.getProperty('recordCanvas'.toJS); if (recordCanvas.isA() && (recordCanvas as JSBoolean).toDart) { web.console.warn( - 'PostHog: canvas session recording is enabled but canvasMaskRegionsFn ' - 'is missing from posthog.init, so text painted by Flutter is recorded ' - 'unmasked. See the posthog_flutter CHANGELOG for the snippet.' + 'PostHog: canvas session recording is enabled but masking is not, so ' + 'text painted by Flutter is recorded unmasked. Mount a ' + 'PostHogMaskWidget to enable masking, or declare ' + 'canvasMaskRegionsFn in posthog.init (see the posthog_flutter ' + 'CHANGELOG for the snippet).' .toJS, ); } diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index d731a992..8b31faa7 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -167,6 +167,47 @@ void main() { expect(startRecordingCalls, 1); }); + testWidgets( + 'a PostHogMaskWidget mount is a no-op when posthog.init already ' + 'opted in', (tester) async { + installPosthogStub(recordingStarted: true); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(setConfigCalls, 1); + + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets( + 'an exception during the mount-triggered apply does not consume the ' + 'opt-in', (tester) async { + final stub = installPosthogStub(declaresMaskProvider: false); + var calls = 0; + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + calls++; + if (calls == 1) { + throw StateError('stub failure'); + } + capturedConfig = cfg; + }).toJS, + ); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(capturedConfig, isNull); + + await tester.pump(const Duration(milliseconds: 600)); + + expect(calls, 2); + expect(capturedConfig, isNotNull); + }); + testWidgets('opts in once posthog-js arrives after the widget mounted', (tester) async { web.window.setProperty('posthog'.toJS, null); From 93402e51ec280140238788e2bcc8f0227afca47a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 28 Jul 2026 17:48:18 +0300 Subject: [PATCH 03/12] docs(changeset): note pre-mount full snapshots can embed unmasked canvas stills Co-Authored-By: Claude Fable 5 --- .changeset/mask-widget-web-canvas.md | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.changeset/mask-widget-web-canvas.md b/.changeset/mask-widget-web-canvas.md index 5341c018..648f1571 100644 --- a/.changeset/mask-widget-web-canvas.md +++ b/.changeset/mask-widget-web-canvas.md @@ -17,11 +17,12 @@ Things to know: - Switching masking on restarts an in-flight recording once, because masking also excludes the Flutter semantics DOM tree via `blockSelector`, which posthog-js only reads when recording starts. You will see the recording split at that point. -- Frames captured before the first `PostHogMaskWidget` mounts are recorded unmasked. - Declaring `maskRegionsFn: () => null` in `posthog.init` is still the only - way to cover the window between page load and Flutter booting, and it moves the - restart to Flutter boot rather than to whenever your first `PostHogMaskWidget` - mounts. +- Frames captured before the first `PostHogMaskWidget` mounts are recorded unmasked, + and full snapshots taken in that window can likewise embed unmasked canvas stills + (`rr_dataURL`). Declaring `maskRegionsFn: () => null` in `posthog.init` is still + the only way to cover the window between page load and Flutter booting, and it + moves the restart to Flutter boot rather than to whenever your first + `PostHogMaskWidget` mounts. - Your app must be wrapped in `PostHogWidget`. If it is not, canvas frames are skipped instead of recorded unmasked, and a console warning explains the fix. From 04c4bcd87b4baabc7d35916a61aa52f1e72cc7c4 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 28 Jul 2026 22:18:58 +0300 Subject: [PATCH 04/12] fix(replay): mark applied only after the recording restart succeeds set_config landing but the restart throwing left _applied latched, so no later pump retried the restart and blockSelector never took effect for the in-flight recording. --- .../lib/src/replay/web/web_canvas_mask_provider.dart | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 6c161fc8..2a95baa8 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -246,7 +246,6 @@ class WebCanvasMaskProvider { final config = JSObject(); config.setProperty('session_recording'.toJS, sessionRecording); ph.set_config(config); - _applied = true; // blockSelector is only read when rrweb's record() starts, so an in-flight // recording must be restarted — worth it even with canvas capture off, @@ -256,6 +255,9 @@ class WebCanvasMaskProvider { ph.stopSessionRecording(); ph.startSessionRecording(); } + // only after the restart: a throw above must leave the apply retryable, + // or the selector never takes effect for this recording + _applied = true; return _ApplyResult.applied; } From e1900cd8da01ea9f7ae7c2a4509b07c5ab9c5aaf Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 29 Jul 2026 18:19:51 +0300 Subject: [PATCH 05/12] fix(replay): only opt in from a PostHogMaskWidget inside the tracked PostHogWidget tree --- .changeset/mask-widget-web-canvas.md | 3 ++ .../mask/canvas_mask_registration_io.dart | 4 +- .../mask/canvas_mask_registration_web.dart | 47 ++++++++++++++++++- .../src/replay/mask/posthog_mask_widget.dart | 2 +- 4 files changed, 53 insertions(+), 3 deletions(-) diff --git a/.changeset/mask-widget-web-canvas.md b/.changeset/mask-widget-web-canvas.md index 648f1571..4f3be8d9 100644 --- a/.changeset/mask-widget-web-canvas.md +++ b/.changeset/mask-widget-web-canvas.md @@ -25,5 +25,8 @@ Things to know: `PostHogMaskWidget` mounts. - Your app must be wrapped in `PostHogWidget`. If it is not, canvas frames are skipped instead of recorded unmasked, and a console warning explains the fix. +- A `PostHogMaskWidget` mounted outside the `PostHogWidget` tree does not opt the + app in: masking could never cover it, so opting in would expose it instead of + failing closed. A debug log explains the misplacement. Apps that declare neither are untouched, exactly as before. diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart index d77f82dd..f9dd9478 100644 --- a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart @@ -1,3 +1,5 @@ +import 'package:flutter/widgets.dart'; + /// Canvas masking is a Flutter web concern; on every other platform /// `PostHogMaskWidget` is honored by the native screenshot pipeline instead. -void notifyMaskWidgetMounted() {} +void notifyMaskWidgetMounted(BuildContext context) {} diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart index eabe37d3..476ee707 100644 --- a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart @@ -1,6 +1,9 @@ import 'package:flutter/scheduler.dart'; +import 'package:flutter/widgets.dart'; +import '../../util/logging.dart'; import '../web/web_canvas_mask_provider.dart'; +import 'posthog_mask_controller.dart'; /// A mounted `PostHogMaskWidget` is an explicit request for masking, so it /// opts the app into canvas masking even when `posthog.init` never declared @@ -9,8 +12,50 @@ import '../web/web_canvas_mask_provider.dart'; /// Deferred to the end of the frame because `initState` runs during Flutter's /// build phase: registering calls straight into posthog-js and restarts an /// in-flight recording. -void notifyMaskWidgetMounted() { +void notifyMaskWidgetMounted(BuildContext context) { SchedulerBinding.instance.addPostFrameCallback((_) { + if (!_isInTrackedTree(context)) { + printIfDebug( + 'PostHog: this PostHogMaskWidget is outside the PostHogWidget tree ' + 'PostHog tracks, so masking could never cover it — it does not ' + 'enable web canvas masking.', + ); + return; + } WebCanvasMaskProvider.notifyMaskWidgetMounted(); }); } + +/// The masking walk only sees PostHogWidget's subtree, so a mask widget +/// outside it would opt masking in while its own rects are never produced — +/// the walk would succeed and ship rects that do not cover the widget. With +/// no tracked tree at all the opt-in stays allowed: every walk then fails and +/// frames are skipped (fail closed), which is the documented behavior for an +/// app missing PostHogWidget. +bool _isInTrackedTree(BuildContext context) { + final trackedContext = + PostHogMaskController.instance.containerKey.currentContext; + if (trackedContext == null) { + return true; + } + final tracked = trackedContext.findRenderObject(); + if (tracked == null) { + // cannot prove the mask widget is outside the tracked tree + return true; + } + if (!context.mounted) { + return false; + } + final renderObject = context.findRenderObject(); + if (renderObject == null) { + return false; + } + RenderObject? node = renderObject; + while (node != null) { + if (identical(node, tracked)) { + return true; + } + node = node.parent; + } + return false; +} diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index 4e23067a..03824506 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -38,7 +38,7 @@ class PostHogMaskWidgetState extends State { @override void initState() { super.initState(); - notifyMaskWidgetMounted(); + notifyMaskWidgetMounted(context); } @override From d1a01b40045dac3523ed4e15cb898e25ca0bf82d Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 29 Jul 2026 18:25:18 +0300 Subject: [PATCH 06/12] fix(replay): enforce a single retry chain; cover tracked-tree mount gating --- .../replay/web/web_canvas_mask_provider.dart | 7 +- .../test/web_canvas_mask_provider_test.dart | 72 +++++++++++++++++++ 2 files changed, 77 insertions(+), 2 deletions(-) diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 34546058..e740da44 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -150,7 +150,6 @@ class WebCanvasMaskProvider { printIfDebug('PostHog: error enabling web canvas masking: $e'); // the opt-in is latched, so keep a chain alive to apply it later — // otherwise a failed first apply would consume the opt-in for good - _polling = true; _scheduleRetry(const Duration(milliseconds: 250)); } } @@ -167,7 +166,6 @@ class WebCanvasMaskProvider { 'PostHog: posthog-js not fully loaded yet, ' 'retrying canvas mask registration.', ); - _polling = true; _scheduleRetry(const Duration(milliseconds: 250)); } @@ -175,6 +173,11 @@ class WebCanvasMaskProvider { // posthog.init minutes after Flutter boots, and giving up would silently // leave its canvas frames skipped (maskRegionsFn stuck at () => null) void _scheduleRetry(Duration delay) { + // every scheduling path must set _polling, and at most one timer may be + // outstanding — a sibling chain surviving here could outlive the + // cancellation in register() and reapply a stale provider's config + _retryTimer?.cancel(); + _polling = true; _retryTimer = Timer(delay, () { var next = delay; try { diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index 93450c25..aeba8e92 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -244,6 +244,78 @@ void main() { expect(setConfigCalls, 1); }); + testWidgets( + 'a retry chain from a throw-path apply cannot outlive a second ' + 'register()', (tester) async { + final stub = installPosthogStub(recordingStarted: true); + var successfulSetConfigs = 0; + var failing = true; + stub.setProperty( + 'set_config'.toJS, + ((JSObject cfg) { + if (failing) { + throw StateError('stub failure'); + } + successfulSetConfigs++; + capturedConfig = cfg; + }).toJS, + ); + + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + // the first apply threw, so a retry is pending; a mask widget mounting + // now must join that chain instead of starting a second one + await tester.pumpWidget(const PostHogMaskWidget(child: SizedBox.shrink())); + expect(successfulSetConfigs, 0); + + failing = false; + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + expect(successfulSetConfigs, 1); + + // an orphaned first-chain timer would fire here and apply a second time + await tester.pump(const Duration(seconds: 5)); + + expect(successfulSetConfigs, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + + testWidgets( + 'a mask widget outside the tracked PostHogWidget tree does not opt in', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + Expanded(child: PostHogWidget(child: Container())), + const PostHogMaskWidget(child: SizedBox.shrink()), + ], + ), + ), + ); + + expect(setConfigCalls, 0); + expect(stopRecordingCalls, 0); + expect(startRecordingCalls, 0); + }); + + testWidgets('a mask widget inside the tracked PostHogWidget tree opts in', + (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + await tester.pumpWidget( + PostHogWidget(child: PostHogMaskWidget(child: const SizedBox.shrink())), + ); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + test('registers the mask provider via set_config', () { installPosthogStub(); From 8be5469e983ba57340ceaa57242997603af4e802 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 29 Jul 2026 19:56:54 +0300 Subject: [PATCH 07/12] docs(replay): scope the outside-tree opt-in claim to mount time; pin the ordering Co-Authored-By: Claude Fable 5 --- .changeset/mask-widget-web-canvas.md | 5 +++- .../mask/canvas_mask_registration_web.dart | 5 ++++ .../test/web_canvas_mask_provider_test.dart | 27 +++++++++++++++++++ 3 files changed, 36 insertions(+), 1 deletion(-) diff --git a/.changeset/mask-widget-web-canvas.md b/.changeset/mask-widget-web-canvas.md index 4f3be8d9..3e6a7389 100644 --- a/.changeset/mask-widget-web-canvas.md +++ b/.changeset/mask-widget-web-canvas.md @@ -27,6 +27,9 @@ Things to know: skipped instead of recorded unmasked, and a console warning explains the fix. - A `PostHogMaskWidget` mounted outside the `PostHogWidget` tree does not opt the app in: masking could never cover it, so opting in would expose it instead of - failing closed. A debug log explains the misplacement. + failing closed. A debug log explains the misplacement. This check runs once, at + the mask widget's first mount: a mask widget that mounts before any + `PostHogWidget` exists is treated as the no-`PostHogWidget` shape above and + does opt in. Apps that declare neither are untouched, exactly as before. diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart index 476ee707..9ca96412 100644 --- a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart @@ -32,6 +32,11 @@ void notifyMaskWidgetMounted(BuildContext context) { /// no tracked tree at all the opt-in stays allowed: every walk then fails and /// frames are skipped (fail closed), which is the documented behavior for an /// app missing PostHogWidget. +/// +/// The check runs once, in the mount's post-frame callback: a null tracked +/// context at that moment is treated as the no-PostHogWidget shape and +/// allowed, even if a PostHogWidget later mounts without containing this +/// widget. bool _isInTrackedTree(BuildContext context) { final trackedContext = PostHogMaskController.instance.containerKey.currentContext; diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index 74e7eb43..fc151e41 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -302,6 +302,33 @@ void main() { expect(startRecordingCalls, 0); }); + testWidgets( + 'a mask widget that mounts before any PostHogWidget opts in ' + '(no-PostHogWidget shape)', (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + Widget layout({required bool withPostHogWidget}) => Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + if (withPostHogWidget) + Expanded(child: PostHogWidget(child: Container())), + const PostHogMaskWidget(child: SizedBox.shrink()), + ], + ), + ); + + await tester.pumpWidget(layout(withPostHogWidget: false)); + expect(setConfigCalls, 1); + + await tester.pumpWidget(layout(withPostHogWidget: true)); + + expect(setConfigCalls, 1); + expect(stopRecordingCalls, 1); + expect(startRecordingCalls, 1); + }); + testWidgets('a mask widget inside the tracked PostHogWidget tree opts in', (tester) async { installPosthogStub(declaresMaskProvider: false, recordingStarted: true); From 78151711760f7983c52e8190f7c35061815abb1a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 30 Jul 2026 16:50:46 +0300 Subject: [PATCH 08/12] docs(changeset): rewrite the mask-widget entry per changelog style Co-Authored-By: Claude Fable 5 --- .changeset/mask-widget-web-canvas.md | 32 +--------------------------- 1 file changed, 1 insertion(+), 31 deletions(-) diff --git a/.changeset/mask-widget-web-canvas.md b/.changeset/mask-widget-web-canvas.md index 3e6a7389..4b4132d9 100644 --- a/.changeset/mask-widget-web-canvas.md +++ b/.changeset/mask-widget-web-canvas.md @@ -2,34 +2,4 @@ "posthog_flutter": minor --- -**`PostHogMaskWidget` now works on Flutter web without any HTML setup.** - -Wrapping a widget in `PostHogMaskWidget` is the most explicit way to say "never -record this", so on web the first one to mount now switches canvas masking on by -itself — no `maskRegionsFn` in `posthog.init` required. `PostHogMaskWidget` -therefore behaves the same on web as it does on iOS and Android. - -Things to know: -- Mounting one `PostHogMaskWidget` enables your whole masking configuration, not - just the wrapped subtree: `maskAllTexts` and `maskAllImages` default to true, so - a single mask widget turns on full text and image canvas masking — the same - semantics as mounting one on iOS and Android. -- Switching masking on restarts an in-flight recording once, because masking also - excludes the Flutter semantics DOM tree via `blockSelector`, which posthog-js only - reads when recording starts. You will see the recording split at that point. -- Frames captured before the first `PostHogMaskWidget` mounts are recorded unmasked, - and full snapshots taken in that window can likewise embed unmasked canvas stills - (`rr_dataURL`). Declaring `maskRegionsFn: () => null` in `posthog.init` is still - the only way to cover the window between page load and Flutter booting, and it - moves the restart to Flutter boot rather than to whenever your first - `PostHogMaskWidget` mounts. -- Your app must be wrapped in `PostHogWidget`. If it is not, canvas frames are - skipped instead of recorded unmasked, and a console warning explains the fix. -- A `PostHogMaskWidget` mounted outside the `PostHogWidget` tree does not opt the - app in: masking could never cover it, so opting in would expose it instead of - failing closed. A debug log explains the misplacement. This check runs once, at - the mask widget's first mount: a mask widget that mounts before any - `PostHogWidget` exists is treated as the no-`PostHogWidget` shape above and - does opt in. - -Apps that declare neither are untouched, exactly as before. +Change `PostHogMaskWidget` to enable web canvas masking on its own: the first mount opts the app in without the `posthog.init` declaration, restarting an in-flight recording once From 9cbf50f4bad767a7c60a5d489111c051159aa999 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 30 Jul 2026 17:00:50 +0300 Subject: [PATCH 09/12] docs(changeset): fold the mask-widget entry into the feature changeset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit One feature, one entry — the stacked PR merges into its base before release. Co-Authored-By: Claude Fable 5 --- .changeset/canvas-masking-web.md | 2 +- .changeset/mask-widget-web-canvas.md | 5 ----- 2 files changed, 1 insertion(+), 6 deletions(-) delete mode 100644 .changeset/mask-widget-web-canvas.md diff --git a/.changeset/canvas-masking-web.md b/.changeset/canvas-masking-web.md index a608164b..8242424c 100644 --- a/.changeset/canvas-masking-web.md +++ b/.changeset/canvas-masking-web.md @@ -2,4 +2,4 @@ "posthog_flutter": minor --- -Add session replay canvas masking on Flutter web: `maskAllTexts`, `maskAllImages`, `PostHogMaskWidget`, and obscured text fields now apply to the CanvasKit canvas (requires posthog-js 1.408.0+; enable by declaring `session_recording.canvasCapture.maskRegionsFn` in `posthog.init`) +Add session replay canvas masking on Flutter web: `maskAllTexts`, `maskAllImages`, `PostHogMaskWidget`, and obscured text fields now apply to the CanvasKit canvas — enable by declaring `session_recording.canvasCapture.maskRegionsFn` in `posthog.init`, or just by mounting a `PostHogMaskWidget` (requires posthog-js 1.408.0+) diff --git a/.changeset/mask-widget-web-canvas.md b/.changeset/mask-widget-web-canvas.md deleted file mode 100644 index 4b4132d9..00000000 --- a/.changeset/mask-widget-web-canvas.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"posthog_flutter": minor ---- - -Change `PostHogMaskWidget` to enable web canvas masking on its own: the first mount opts the app in without the `posthog.init` declaration, restarting an in-flight recording once From 7eec53211ea8bb316b7ae657fc5e4d94a7eb82ee Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 30 Jul 2026 17:13:06 +0300 Subject: [PATCH 10/12] fix(replay): revalidate mounted mask widgets on every frame, fail closed outside the tracked tree The mount-time check latches the opt-in once; a PostHogWidget mounting later without containing the mask widget would ship rects that never cover it. Every maskRegionsFn call now verifies all mounted mask widgets are inside the tracked tree and skips the frame otherwise. Co-Authored-By: Claude Fable 5 --- .../mask/canvas_mask_registration_io.dart | 2 + .../mask/canvas_mask_registration_web.dart | 12 +- .../src/replay/mask/posthog_mask_widget.dart | 7 +- .../replay/web/web_canvas_mask_provider.dart | 59 +++++++++- .../test/web_canvas_mask_provider_test.dart | 107 +++++++++++++++++- 5 files changed, 181 insertions(+), 6 deletions(-) diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart index f9dd9478..b9e993e1 100644 --- a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dart @@ -3,3 +3,5 @@ import 'package:flutter/widgets.dart'; /// Canvas masking is a Flutter web concern; on every other platform /// `PostHogMaskWidget` is honored by the native screenshot pipeline instead. void notifyMaskWidgetMounted(BuildContext context) {} + +void notifyMaskWidgetUnmounted(BuildContext context) {} diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart index 9ca96412..c2e306f6 100644 --- a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart @@ -13,6 +13,7 @@ import 'posthog_mask_controller.dart'; /// build phase: registering calls straight into posthog-js and restarts an /// in-flight recording. void notifyMaskWidgetMounted(BuildContext context) { + WebCanvasMaskProvider.registerMaskWidgetContext(context); SchedulerBinding.instance.addPostFrameCallback((_) { if (!_isInTrackedTree(context)) { printIfDebug( @@ -26,6 +27,10 @@ void notifyMaskWidgetMounted(BuildContext context) { }); } +void notifyMaskWidgetUnmounted(BuildContext context) { + WebCanvasMaskProvider.unregisterMaskWidgetContext(context); +} + /// The masking walk only sees PostHogWidget's subtree, so a mask widget /// outside it would opt masking in while its own rects are never produced — /// the walk would succeed and ship rects that do not cover the widget. With @@ -35,8 +40,11 @@ void notifyMaskWidgetMounted(BuildContext context) { /// /// The check runs once, in the mount's post-frame callback: a null tracked /// context at that moment is treated as the no-PostHogWidget shape and -/// allowed, even if a PostHogWidget later mounts without containing this -/// widget. +/// allowed. That one-shot allowance is backstopped by +/// [WebCanvasMaskProvider], which revalidates every mounted mask widget when +/// regions are computed — if a PostHogWidget later mounts without containing +/// this widget, frames are skipped (fail closed) rather than recorded +/// unmasked. bool _isInTrackedTree(BuildContext context) { final trackedContext = PostHogMaskController.instance.containerKey.currentContext; diff --git a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart index 03824506..826135ad 100644 --- a/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart +++ b/posthog_flutter/lib/src/replay/mask/posthog_mask_widget.dart @@ -18,8 +18,10 @@ import 'canvas_mask_registration_io.dart' /// `session_recording: { canvasCapture: { maskRegionsFn: () => null } }` /// in your `posthog.init` call — until this plugin takes over, those frames are /// skipped instead of recorded. Your app must be wrapped in `PostHogWidget`, -/// or canvas frames are skipped instead of recorded unmasked. iOS and Android -/// need no setup either way. +/// and every [PostHogMaskWidget] must sit inside it — otherwise canvas frames +/// are skipped instead of recorded unmasked, until the mask widget is moved +/// inside `PostHogWidget` or removed. iOS and Android need no setup either +/// way. class PostHogMaskWidget extends StatefulWidget { /// The widget subtree to mask in session replay snapshots. final Widget child; @@ -43,6 +45,7 @@ class PostHogMaskWidgetState extends State { @override void dispose() { + notifyMaskWidgetUnmounted(context); super.dispose(); } diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index 5747c91d..4e33b0d9 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -6,7 +6,7 @@ import 'dart:ui_web' as ui_web; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart' show View; +import 'package:flutter/widgets.dart' show BuildContext, View; import 'package:web/web.dart' as web; import '../../posthog_config.dart'; @@ -61,6 +61,7 @@ class WebCanvasMaskProvider { static WebCanvasMaskProvider? _active; static bool _maskWidgetSeen = false; static bool _warnedOldPosthogJs = false; + static final Set _mountedMaskWidgets = {}; @visibleForTesting static String? debugMinPosthogJsVersionOverride; @@ -87,11 +88,20 @@ class WebCanvasMaskProvider { } } + static void registerMaskWidgetContext(BuildContext context) { + _mountedMaskWidgets.add(context); + } + + static void unregisterMaskWidgetContext(BuildContext context) { + _mountedMaskWidgets.remove(context); + } + @visibleForTesting static void resetForTesting() { _active?._retryTimer?.cancel(); _active = null; _maskWidgetSeen = false; + _mountedMaskWidgets.clear(); _warnedOldPosthogJs = false; debugMinPosthogJsVersionOverride = null; debugOwnViewHostOverride = null; @@ -104,6 +114,7 @@ class WebCanvasMaskProvider { int _cachedAtFrame = -1; int _consecutiveWalkFailures = 0; bool _warnedWalkFailure = false; + bool _warnedMaskWidgetOutsideTree = false; bool _applied = false; bool _maskWidgetMounted = false; bool _polling = false; @@ -411,6 +422,18 @@ class WebCanvasMaskProvider { } _consecutiveWalkFailures = 0; + if (!_maskWidgetsInsideTrackedTree()) { + if (!_warnedMaskWidgetOutsideTree) { + _warnedMaskWidgetOutsideTree = true; + printIfDebug( + 'PostHog: a PostHogMaskWidget is mounted outside the PostHogWidget ' + 'tree, so masking could never cover it — canvas frames are skipped ' + 'until it is moved inside PostHogWidget or removed.', + ); + } + return null; + } + // our rects always describe PostHogWidget's tree — shipping them with a // different flutter-view's canvas would record that view unmasked if (!_isOwnViewCanvas(host)) { @@ -488,6 +511,40 @@ class WebCanvasMaskProvider { return null; } + // Re-checked on every frame, not only at mount: a mask widget that mounted + // before any PostHogWidget latches the opt-in, and a PostHogWidget mounting + // later without containing it would otherwise ship rects that never cover + // the widget. Deliberately outside the per-frame rects cache, so a cached + // value cannot outlive a tree change. + bool _maskWidgetsInsideTrackedTree() { + if (_mountedMaskWidgets.isEmpty) { + return true; + } + final tracked = PostHogMaskController.instance.containerKey.currentContext + ?.findRenderObject(); + if (tracked == null) { + return false; + } + for (final context in _mountedMaskWidgets) { + if (!context.mounted) { + continue; + } + var inside = false; + RenderObject? node = context.findRenderObject(); + while (node != null) { + if (identical(node, tracked)) { + inside = true; + break; + } + node = node.parent; + } + if (!inside) { + return false; + } + } + return true; + } + // rects only change when Flutter paints a frame, so cache per frame instead // of recomputing on every posthog-js canvas tick List? _currentContainerRects() { diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index fc151e41..5dc9b97a 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -304,7 +304,8 @@ void main() { testWidgets( 'a mask widget that mounts before any PostHogWidget opts in ' - '(no-PostHogWidget shape)', (tester) async { + '(no-PostHogWidget shape), but a later PostHogWidget that excludes it ' + 'makes frames fail closed', (tester) async { installPosthogStub(declaresMaskProvider: false, recordingStarted: true); WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); @@ -327,6 +328,110 @@ void main() { expect(setConfigCalls, 1); expect(stopRecordingCalls, 1); expect(startRecordingCalls, 1); + + // the latched opt-in must not ship the sibling tree's rects while this + // mask widget sits outside it + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + expect(regionsFn.callAsFunction(null, canvas), isNull); + } finally { + flutterView.remove(); + } + }); + + testWidgets( + 'a mask widget that mounts before a PostHogWidget that later contains ' + 'it keeps producing regions', (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + WebCanvasMaskProvider(config).register(); + + final maskKey = GlobalKey(); + Widget mask() => Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + key: maskKey, + child: const SizedBox(width: 30, height: 40), + ), + ); + + await tester.pumpWidget(mask()); + expect(setConfigCalls, 1); + + await tester.pumpWidget(PostHogWidget(child: mask())); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = + regionsFn.callAsFunction(null, canvas) as JSArray; + + expect(regions.toDart, hasLength(1)); + final region = regions.toDart.first; + expect( + region.getProperty('width'.toJS).toDartDouble, + greaterThanOrEqualTo(30), + ); + expect( + region.getProperty('height'.toJS).toDartDouble, + greaterThanOrEqualTo(40), + ); + } finally { + flutterView.remove(); + } + }); + + testWidgets( + 'frames recover once the mask widget outside the tracked tree is ' + 'removed', (tester) async { + installPosthogStub(recordingStarted: true); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + WebCanvasMaskProvider(config).register(); + + Widget layout({required bool withOutsideMask}) => Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + Expanded(child: PostHogWidget(child: Container())), + if (withOutsideMask) + const PostHogMaskWidget(child: SizedBox.shrink()), + ], + ), + ); + + await tester.pumpWidget(layout(withOutsideMask: true)); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + expect(regionsFn.callAsFunction(null, canvas), isNull); + + await tester.pumpWidget(layout(withOutsideMask: false)); + + expect(regionsFn.callAsFunction(null, canvas), isNotNull); + } finally { + flutterView.remove(); + } }); testWidgets('a mask widget inside the tracked PostHogWidget tree opts in', From 9672c6970c8b17123dcb8fbfd23dd1b9fd8c83e1 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 30 Jul 2026 17:25:53 +0300 Subject: [PATCH 11/12] test(replay): pin the provider's own view in regions tests The CI harness's engine flutter-view plus each test's fake one make the full-page host ambiguous, so the multi-view fail-closed path returned null before the behavior under test could. Pinning debugOwnViewHostOverride makes every regions assertion hold for its own reason. --- posthog_flutter/test/web_canvas_mask_provider_test.dart | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index a03486df..a30a9978 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -335,6 +335,7 @@ void main() { final canvas = web.document.createElement('canvas'); flutterView.appendChild(canvas); web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; try { final regionsFn = capturedSessionRecording() .getProperty('canvasCapture'.toJS) @@ -372,6 +373,7 @@ void main() { final canvas = web.document.createElement('canvas'); flutterView.appendChild(canvas); web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; try { final regionsFn = capturedSessionRecording() .getProperty('canvasCapture'.toJS) @@ -420,6 +422,7 @@ void main() { final canvas = web.document.createElement('canvas'); flutterView.appendChild(canvas); web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; try { final regionsFn = capturedSessionRecording() .getProperty('canvasCapture'.toJS) @@ -573,6 +576,7 @@ void main() { final canvas = web.document.createElement('canvas'); flutterView.appendChild(canvas); web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; try { expect(regionsFn.callAsFunction(null, canvas), isNull); } finally { @@ -598,6 +602,7 @@ void main() { final canvas = web.document.createElement('canvas'); flutterView.appendChild(canvas); web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; try { final regionsFn = capturedSessionRecording() .getProperty('canvasCapture'.toJS) From e89ff8e2c103878889aebed71bef68c2fc4df675 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 30 Jul 2026 17:37:44 +0300 Subject: [PATCH 12/12] fix(replay): resolve the tracked-tree root the way the masking walk does; guard the mount callback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The walk roots at the root navigator when PostHogWidget sits under an active route, so the mount gate and per-frame revalidation now share that resolution — a mask widget in a root-navigator dialog is tracked, not rejected. The post-frame callback body is try/catch-wrapped so a throw cannot surface through FlutterError.onError into the host's error tracking. Co-Authored-By: Claude Fable 5 --- .../mask/canvas_mask_registration_web.dart | 30 ++++++---- .../replay/web/web_canvas_mask_provider.dart | 33 +++++++++-- .../test/web_canvas_mask_provider_test.dart | 58 +++++++++++++++++++ 3 files changed, 105 insertions(+), 16 deletions(-) diff --git a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart index c2e306f6..a606d271 100644 --- a/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart +++ b/posthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dart @@ -15,15 +15,19 @@ import 'posthog_mask_controller.dart'; void notifyMaskWidgetMounted(BuildContext context) { WebCanvasMaskProvider.registerMaskWidgetContext(context); SchedulerBinding.instance.addPostFrameCallback((_) { - if (!_isInTrackedTree(context)) { - printIfDebug( - 'PostHog: this PostHogMaskWidget is outside the PostHogWidget tree ' - 'PostHog tracks, so masking could never cover it — it does not ' - 'enable web canvas masking.', - ); - return; + try { + if (!_isInTrackedTree(context)) { + printIfDebug( + 'PostHog: this PostHogMaskWidget is outside the PostHogWidget tree ' + 'PostHog tracks, so masking could never cover it — it does not ' + 'enable web canvas masking.', + ); + return; + } + WebCanvasMaskProvider.notifyMaskWidgetMounted(); + } catch (e) { + printIfDebug('PostHog: error enabling web canvas masking: $e'); } - WebCanvasMaskProvider.notifyMaskWidgetMounted(); }); } @@ -31,9 +35,11 @@ void notifyMaskWidgetUnmounted(BuildContext context) { WebCanvasMaskProvider.unregisterMaskWidgetContext(context); } -/// The masking walk only sees PostHogWidget's subtree, so a mask widget -/// outside it would opt masking in while its own rects are never produced — -/// the walk would succeed and ship rects that do not cover the widget. With +/// The masking walk only sees the tracked tree (whose route-dependent root is +/// [WebCanvasMaskProvider.trackedTreeRoot], the boundary this check walks +/// against), so a mask widget outside it would opt masking in while its own +/// rects are never produced — the walk would succeed and ship rects that do +/// not cover the widget. With /// no tracked tree at all the opt-in stays allowed: every walk then fails and /// frames are skipped (fail closed), which is the documented behavior for an /// app missing PostHogWidget. @@ -51,7 +57,7 @@ bool _isInTrackedTree(BuildContext context) { if (trackedContext == null) { return true; } - final tracked = trackedContext.findRenderObject(); + final tracked = WebCanvasMaskProvider.trackedTreeRoot(trackedContext); if (tracked == null) { // cannot prove the mask widget is outside the tracked tree return true; diff --git a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart index f4a9287e..88d21e0c 100644 --- a/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart +++ b/posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart @@ -6,7 +6,8 @@ import 'dart:ui_web' as ui_web; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; -import 'package:flutter/widgets.dart' show BuildContext, View; +import 'package:flutter/widgets.dart' + show BuildContext, ModalRoute, Navigator, View; import 'package:web/web.dart' as web; import '../../posthog_config.dart'; @@ -92,6 +93,25 @@ class WebCanvasMaskProvider { _mountedMaskWidgets.add(context); } + /// The render object rooting the tree the masking walk actually covers, + /// mirroring `RootElementProvider`: when the tracked context sits under an + /// active route the walk roots at the root navigator (so it covers dialogs + /// and other routes), otherwise at the tracked context itself. Every + /// tracked-tree membership check must use this same root, or a mask widget + /// in a dialog the walk covers would be treated as untracked. + static RenderObject? trackedTreeRoot(BuildContext trackedContext) { + try { + if (ModalRoute.of(trackedContext)?.isActive ?? false) { + return Navigator.of(trackedContext, rootNavigator: true) + .context + .findRenderObject(); + } + } catch (e) { + printIfDebug('PostHog: could not resolve the tracked-tree root: $e'); + } + return trackedContext.findRenderObject(); + } + static void unregisterMaskWidgetContext(BuildContext context) { _mountedMaskWidgets.remove(context); } @@ -527,13 +547,18 @@ class WebCanvasMaskProvider { // before any PostHogWidget latches the opt-in, and a PostHogWidget mounting // later without containing it would otherwise ship rects that never cover // the widget. Deliberately outside the per-frame rects cache, so a cached - // value cannot outlive a tree change. + // value cannot outlive a tree change. The boundary is [trackedTreeRoot], + // the walk's own route-dependent root — not PostHogWidget's subtree. bool _maskWidgetsInsideTrackedTree() { if (_mountedMaskWidgets.isEmpty) { return true; } - final tracked = PostHogMaskController.instance.containerKey.currentContext - ?.findRenderObject(); + final trackedContext = + PostHogMaskController.instance.containerKey.currentContext; + if (trackedContext == null) { + return false; + } + final tracked = trackedTreeRoot(trackedContext); if (tracked == null) { return false; } diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index a30a9978..cfc7d594 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -451,6 +451,64 @@ void main() { expect(startRecordingCalls, 1); }); + testWidgets( + 'a mask widget in a dialog above PostHogWidget opts in and produces ' + 'regions', (tester) async { + installPosthogStub(declaresMaskProvider: false, recordingStarted: true); + final config = PostHogConfig('phc_test') + ..sessionReplayConfig.maskAllTexts = false + ..sessionReplayConfig.maskAllImages = false; + WebCanvasMaskProvider(config).register(); + expect(setConfigCalls, 0); + + // PostHogWidget sits UNDER the home route, so the walk roots at the root + // navigator and covers the dialog — the tracked-tree boundary must too + await tester.pumpWidget( + MaterialApp( + home: PostHogWidget( + child: Builder( + builder: (context) => TextButton( + onPressed: () { + showDialog( + context: context, + builder: (_) => Align( + alignment: Alignment.topLeft, + child: PostHogMaskWidget( + child: const SizedBox(width: 30, height: 40), + ), + ), + ); + }, + child: const Text('open'), + ), + ), + ), + ), + ); + expect(setConfigCalls, 0); + + await tester.tap(find.text('open')); + await tester.pumpAndSettle(); + + expect(setConfigCalls, 1); + + final flutterView = web.document.createElement('flutter-view'); + final canvas = web.document.createElement('canvas'); + flutterView.appendChild(canvas); + web.document.body!.appendChild(flutterView); + WebCanvasMaskProvider.debugOwnViewHostOverride = flutterView; + try { + final regionsFn = capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + final regions = regionsFn.callAsFunction(null, canvas); + expect(regions, isNotNull); + expect((regions as JSArray).toDart, isNotEmpty); + } finally { + flutterView.remove(); + } + }); + test('registers the mask provider via set_config', () { installPosthogStub();