Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/web-mask-regions-delegate.md
Original file line number Diff line number Diff line change
@@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand All @@ -64,6 +69,13 @@ class WebCanvasMaskProvider {
static bool _warnedOldPosthogJs = false;
static final Set<BuildContext> _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;

Expand Down Expand Up @@ -123,6 +135,8 @@ class WebCanvasMaskProvider {
_maskWidgetSeen = false;
_mountedMaskWidgets.clear();
_warnedOldPosthogJs = false;
_appMaskRegionsFn = null;
_appMaskRegionsFnCaptured = false;
debugMinPosthogJsVersionOverride = null;
debugOwnViewHostOverride = null;
}
Expand Down Expand Up @@ -283,6 +297,13 @@ class WebCanvasMaskProvider {
}
_warnIfPosthogJsTooOld(ph);
_ensureFrameCounter();
if (!_appMaskRegionsFnCaptured) {
_appMaskRegionsFnCaptured = true;
final declared = canvasCapture.getProperty<JSAny?>('maskRegionsFn'.toJS);
if (declared.isA<JSFunction>()) {
_appMaskRegionsFn = declared as JSFunction;
}
}
canvasCapture.setProperty(
'maskRegionsFn'.toJS,
_computeMaskRegions.toJS,
Expand Down Expand Up @@ -421,7 +442,7 @@ class WebCanvasMaskProvider {
}

// null tells posthog-js to skip the frame rather than ship it unmasked
JSArray<JSObject>? _computeMaskRegions(web.HTMLCanvasElement canvas) {
JSAny? _computeMaskRegions(web.HTMLCanvasElement canvas) {
try {
return _unsafeComputeMaskRegions(canvas);
} catch (e) {
Expand All @@ -430,10 +451,30 @@ class WebCanvasMaskProvider {
}
}

JSArray<JSObject>? _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<JSObject>();
}
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<JSObject>();
return _delegateToAppMaskRegionsFn(canvas);
}

// our rects always describe PostHogWidget's tree β€” shipping them with a
Expand Down
92 changes: 92 additions & 0 deletions posthog_flutter/test/web_canvas_mask_provider_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<JSObject>('canvasCapture'.toJS)
.getProperty<JSFunction>('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<JSObject>).toDart;
expect(regions, hasLength(1));
expect(regions.single.getProperty<JSNumber>('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<JSObject>();
}).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', () {
Expand Down
Loading