From 29419fb2770ff5d927924c9ae5a9e42755a5656a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 30 Jul 2026 19:09:30 +0300 Subject: [PATCH] fix(replay): keep the app's maskRegionsFn for canvases outside the flutter view --- .changeset/web-mask-regions-delegate.md | 5 + .../replay/web/web_canvas_mask_provider.dart | 47 +++++++++- .../test/web_canvas_mask_provider_test.dart | 92 +++++++++++++++++++ 3 files changed, 141 insertions(+), 3 deletions(-) create mode 100644 .changeset/web-mask-regions-delegate.md diff --git a/.changeset/web-mask-regions-delegate.md b/.changeset/web-mask-regions-delegate.md new file mode 100644 index 00000000..273f47e1 --- /dev/null +++ b/.changeset/web-mask-regions-delegate.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Fix web canvas masking discarding an app-provided `maskRegionsFn` — canvases outside the Flutter view now get the app's original callback instead of recording unmasked 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 13665369..508aa593 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 @@ -56,6 +56,11 @@ enum _ApplyResult { /// /// Fails closed: a failed widget-tree walk returns null, which makes /// posthog-js skip the frame instead of shipping it unmasked. +/// +/// An app-declared `maskRegionsFn` is not discarded by the takeover: the +/// provider answers only for canvases inside a flutter-view and hands any +/// other canvas back to the app's original callback, so a hybrid page keeps +/// the masking it configured for its own canvases. class WebCanvasMaskProvider { WebCanvasMaskProvider(this._config); @@ -64,6 +69,13 @@ class WebCanvasMaskProvider { static bool _warnedOldPosthogJs = false; static final Set _mountedMaskWidgets = {}; + // The app's own maskRegionsFn, read once before the first apply replaces it + // in posthog-js config — every later read would see this provider's own + // installed callback. Static so a second setup()'s provider cannot mistake + // the first provider's callback for the app's. + static JSFunction? _appMaskRegionsFn; + static bool _appMaskRegionsFnCaptured = false; + @visibleForTesting static String? debugMinPosthogJsVersionOverride; @@ -123,6 +135,8 @@ class WebCanvasMaskProvider { _maskWidgetSeen = false; _mountedMaskWidgets.clear(); _warnedOldPosthogJs = false; + _appMaskRegionsFn = null; + _appMaskRegionsFnCaptured = false; debugMinPosthogJsVersionOverride = null; debugOwnViewHostOverride = null; } @@ -283,6 +297,13 @@ class WebCanvasMaskProvider { } _warnIfPosthogJsTooOld(ph); _ensureFrameCounter(); + if (!_appMaskRegionsFnCaptured) { + _appMaskRegionsFnCaptured = true; + final declared = canvasCapture.getProperty('maskRegionsFn'.toJS); + if (declared.isA()) { + _appMaskRegionsFn = declared as JSFunction; + } + } canvasCapture.setProperty( 'maskRegionsFn'.toJS, _computeMaskRegions.toJS, @@ -421,7 +442,7 @@ class WebCanvasMaskProvider { } // null tells posthog-js to skip the frame rather than ship it unmasked - JSArray? _computeMaskRegions(web.HTMLCanvasElement canvas) { + JSAny? _computeMaskRegions(web.HTMLCanvasElement canvas) { try { return _unsafeComputeMaskRegions(canvas); } catch (e) { @@ -430,10 +451,30 @@ class WebCanvasMaskProvider { } } - JSArray? _unsafeComputeMaskRegions(web.HTMLCanvasElement canvas) { + // A canvas outside every flutter-view is one this provider cannot describe; + // answering `[]` for it would record hybrid-page canvases unmasked even when + // the app's own maskRegionsFn had masked them, so those canvases keep the + // app's callback. posthog-js reads `undefined` as record-unmasked and `null` + // as mask-fully, but js_interop conflates the two on the way back through + // Dart — an app answer of `undefined` therefore comes out as the fail-closed + // `null`. + JSAny? _delegateToAppMaskRegionsFn(web.HTMLCanvasElement canvas) { + final appFn = _appMaskRegionsFn; + if (appFn == null) { + return JSArray(); + } + try { + return appFn.callAsFunction(null, canvas); + } catch (e) { + printIfDebug('PostHog: app-provided maskRegionsFn threw: $e'); + return null; + } + } + + JSAny? _unsafeComputeMaskRegions(web.HTMLCanvasElement canvas) { final host = _flutterViewHost(canvas); if (host == null) { - return JSArray(); + return _delegateToAppMaskRegionsFn(canvas); } // our rects always describe PostHogWidget's tree — shipping them with a diff --git a/posthog_flutter/test/web_canvas_mask_provider_test.dart b/posthog_flutter/test/web_canvas_mask_provider_test.dart index 5950e10e..fdd23113 100644 --- a/posthog_flutter/test/web_canvas_mask_provider_test.dart +++ b/posthog_flutter/test/web_canvas_mask_provider_test.dart @@ -621,6 +621,98 @@ void main() { expect(regions.toDart, isEmpty); }); + group('app-provided maskRegionsFn delegation', () { + JSObject sessionRecordingWithAppFn(JSFunction appFn) { + final canvasCapture = JSObject() + ..setProperty('maskRegionsFn'.toJS, appFn); + return JSObject()..setProperty('canvasCapture'.toJS, canvasCapture); + } + + JSFunction installedRegionsFn() => capturedSessionRecording() + .getProperty('canvasCapture'.toJS) + .getProperty('maskRegionsFn'.toJS); + + test('a canvas outside every flutter view gets the app callback answer', + () { + var appFnCalls = 0; + final appFn = ((web.HTMLCanvasElement c) { + appFnCalls++; + return [JSObject()..setProperty('x'.toJS, 42.toJS)].toJS; + }).toJS; + installPosthogStub( + declaresMaskProvider: false, + sessionRecording: sessionRecordingWithAppFn(appFn), + ); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final canvas = web.document.createElement('canvas'); + final result = installedRegionsFn().callAsFunction(null, canvas); + + expect(appFnCalls, 1); + final regions = (result as JSArray).toDart; + expect(regions, hasLength(1)); + expect(regions.single.getProperty('x'.toJS).toDartInt, 42); + }); + + test('an app callback answering null keeps its fail-closed meaning', () { + JSAny? nullAppFn(web.HTMLCanvasElement c) => null; + final appFn = nullAppFn.toJS; + installPosthogStub( + declaresMaskProvider: false, + sessionRecording: sessionRecordingWithAppFn(appFn), + ); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final canvas = web.document.createElement('canvas'); + expect(installedRegionsFn().callAsFunction(null, canvas), isNull); + }); + + test('a throwing app callback fails closed', () { + JSAny? throwingAppFn(web.HTMLCanvasElement c) { + throw StateError('app fn exploded'); + } + + final appFn = throwingAppFn.toJS; + installPosthogStub( + declaresMaskProvider: false, + sessionRecording: sessionRecordingWithAppFn(appFn), + ); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final canvas = web.document.createElement('canvas'); + expect(installedRegionsFn().callAsFunction(null, canvas), isNull); + }); + + test( + 'a second setup does not mistake the installed callback for the app ' + 'fn', () { + var appFnCalls = 0; + final appFn = ((web.HTMLCanvasElement c) { + appFnCalls++; + return JSArray(); + }).toJS; + installPosthogStub( + declaresMaskProvider: false, + sessionRecording: sessionRecordingWithAppFn(appFn), + ); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + // posthog-js merges set_config into its live config, so a second + // setup()'s apply reads back the provider's own installed callback + final firstApply = capturedSessionRecording(); + installPosthogStub( + declaresMaskProvider: false, + sessionRecording: firstApply, + ); + WebCanvasMaskProvider(PostHogConfig('phc_test')).register(); + + final canvas = web.document.createElement('canvas'); + installedRegionsFn().callAsFunction(null, canvas); + + expect(appFnCalls, 1); + }); + }); + test( 'fails closed for a flutter-view canvas when no PostHogWidget is ' 'mounted', () {