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
75 changes: 16 additions & 59 deletions packages/formbricks/lib/src/widgets/survey_html.dart
Original file line number Diff line number Diff line change
Expand Up @@ -153,63 +153,21 @@ String buildSurveyHtml(SurveyHtmlOptions options) {
function getSetIsResponseSendingFinished() { /* noop */ };
function getSetIsError() { /* noop */ };

// Reports the survey card's bounding rect (CSS px, viewport-relative) to
// the host so it can pass touches outside the card through to the app
// (box-none). The native WebView hit-tests its whole rectangle and ignores
// the page's `pointer-events:none`, so the host masks pointers itself and
// needs the card geometry to know where the card is.
var fbLastGeometry = '';
var fbGeometryRaf = null;
var fbGeometryStable = 0;
function fbCardRect() {
// The survey card is the single dialog the runtime renders inside its
// #fbjs container (survey-container.tsx). Scoped to #fbjs so we never
// grab the full-screen wrapper (that would defeat box-none);
// querySelector returns the outermost match in document order, i.e.
// the card itself.
//
// Deliberately NOT matched on aria-modal. The runtime sets that
// attribute only when the survey has a backdrop (ENG-2304), so
// requiring it matched nothing in exactly the no-overlay case this
// mask exists for -- the rect stayed null and the card became
// untappable. Match on role alone: it is on the card in every mode.
var el = document.querySelector('#fbjs [role="dialog"]');
if (!el) return null;
var r = el.getBoundingClientRect();
if (r.width <= 0 || r.height <= 0) return null;
return { x: r.left, y: r.top, width: r.width, height: r.height };
}
function fbGeometryTick() {
var rect = fbCardRect();
var key = rect
? [Math.round(rect.x), Math.round(rect.y), Math.round(rect.width), Math.round(rect.height)].join(',')
: 'null';
if (key !== fbLastGeometry) {
fbLastGeometry = key;
fbGeometryStable = 0;
postFormbricksMessage({ type: 'Geometry', data: rect });
} else {
fbGeometryStable++;
}
// Idle once the rect has been stable for ~1.5s (open/step animations
// settle); observers below restart the loop on any later change.
if (fbGeometryStable > 90) { fbGeometryRaf = null; return; }
fbGeometryRaf = window.requestAnimationFrame(fbGeometryTick);
}
function fbEnsureGeometryLoop() {
if (fbGeometryRaf == null) {
fbGeometryStable = 0;
fbGeometryRaf = window.requestAnimationFrame(fbGeometryTick);
}
}
function fbObserveGeometry() {
try {
var mo = new MutationObserver(fbEnsureGeometryLoop);
mo.observe(document.documentElement, { childList: true, subtree: true, attributes: true });
} catch (e) {}
window.addEventListener('resize', fbEnsureGeometryLoop);
window.addEventListener('orientationchange', fbEnsureGeometryLoop);
}
// Where the survey card is, so the host can pass pointers outside it through
// to the app. The renderer measures and calls this (ENG-3155); `rect` is
// null when no card is on screen.
//
// This used to be scraped out of the DOM here. A correct a11y fix upstream
// moved the attribute that probe matched on, the rect went null forever and
// the card became untappable — with nothing to catch it, because a selector
// in a string has no compile step and the renderer is fetched at runtime.
//
// Only a renderer from Formbricks 6.0+ calls this. Against an older
// self-hosted server it never fires, and the mask stays at
// SurveyTouchRegion.everything — exactly how the SDK behaved before.
function onCardRectChange(rect) {
postFormbricksMessage({ type: 'Geometry', data: rect });
};

let closedForError = false;
function closeOnError(message, error) {
Expand All @@ -228,6 +186,7 @@ String buildSurveyHtml(SurveyHtmlOptions options) {
onResponseCreated,
onFinished,
onClose,
onCardRectChange,
getSetIsResponseSendingFinished,
getSetIsError,
};
Expand All @@ -238,8 +197,6 @@ String buildSurveyHtml(SurveyHtmlOptions options) {
return;
}
runtime.renderSurvey(surveyProps);
fbObserveGeometry();
fbEnsureGeometryLoop();
} catch (error) {
closeOnError('Failed to render Formbricks survey:', error);
}
Expand Down
76 changes: 76 additions & 0 deletions packages/formbricks/lib/src/widgets/survey_touch_region.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import 'dart:ui';

/// Which pointers over the survey's full-screen WebView belong to the survey,
/// and which should fall through to the host app underneath.
///
/// A platform WebView hit-tests its entire rectangle. The shared renderer sets
/// `pointer-events: none` outside the card, but that is a *web* hit test that
/// Flutter never sees, so a transparent full-screen WebView still swallows
/// every pointer and the host app appears frozen.
///
/// Three states rather than two, because "no rect has arrived" and "the card is
/// not on screen" need opposite answers. Conflating them is what made the card
/// untappable when the old DOM probe stopped matching (ENG-3155): a missing rect
/// was read as "claim nothing", so the survey itself stopped responding.
sealed class SurveyTouchRegion {
/// Creates a region.
const SurveyTouchRegion();

/// Every pointer belongs to the survey.
///
/// Correct for a `light` or `dark` overlay, whose visible backdrop is meant to
/// block the host app. Also the starting state for a no-overlay survey, and it
/// stays that way if the renderer never reports a rect — an older self-hosted
/// server serves a bundle without `onCardRectChange`, and behaving exactly as
/// the SDK always did is the safe answer there.
static const SurveyTouchRegion everything = _Everything();

/// Nothing belongs to the survey, because no card is on screen.
///
/// The renderer reports this while the card animates out, and the card is
/// hidden for a full second before the close arrives. Without it the SDK
/// leaves a dead patch over a host app that looks perfectly usable.
static const SurveyTouchRegion nothing = _Nothing();

/// Maps a rect reported by the renderer onto a region. A missing rect means
/// the card is not on screen — never "the feature is absent", which is
/// [everything].
static SurveyTouchRegion forReported(Rect? rect) =>
rect == null ? nothing : SurveyTouchRegion.card(rect);

/// Only pointers inside [rect] belong to the survey.
const factory SurveyTouchRegion.card(Rect rect) = _Card;

/// Whether a pointer at [position] belongs to the survey.
bool accepts(Offset position);
}

final class _Everything extends SurveyTouchRegion {
const _Everything();

@override
bool accepts(Offset position) => true;
}

final class _Nothing extends SurveyTouchRegion {
const _Nothing();

@override
bool accepts(Offset position) => false;
}

final class _Card extends SurveyTouchRegion {
const _Card(this.rect);

final Rect rect;

@override
bool accepts(Offset position) => rect.contains(position);

@override
bool operator ==(Object other) =>
identical(this, other) || (other is _Card && other.rect == rect);

@override
int get hashCode => rect.hashCode;
}
38 changes: 21 additions & 17 deletions packages/formbricks/lib/src/widgets/survey_webview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import '../types/survey.dart';
import '../user/interaction_refresh.dart';
import 'default_webview_host.dart';
import 'survey_html.dart';
import 'survey_touch_region.dart';
import 'webview_event.dart';
import 'webview_navigation.dart';

Expand Down Expand Up @@ -75,7 +76,10 @@ class _SurveyWebViewState extends State<SurveyWebView> {
// placements keep the full-screen modal route (the backdrop *should* block).
OverlayEntry? _overlayEntry;
bool _hasOverlay = false;
final ValueNotifier<Rect?> _cardRect = ValueNotifier<Rect?>(null);
// Starts at `everything`, so the SDK blocks pointers exactly as it used to
// until the renderer tells us where the card is. See [SurveyTouchRegion].
final ValueNotifier<SurveyTouchRegion> _touchRegion =
ValueNotifier<SurveyTouchRegion>(SurveyTouchRegion.everything);

// Serializes config read-modify-writes so back-to-back events (e.g. response
// then close) can't clobber each other.
Expand Down Expand Up @@ -249,9 +253,10 @@ class _SurveyWebViewState extends State<SurveyWebView> {
// full-bleed (so shadows show) but rejects hits elsewhere. Passing the
// WebView as `child` keeps its controller alive across geometry updates.
if (_hasOverlay) return webView;
return ValueListenableBuilder<Rect?>(
valueListenable: _cardRect,
builder: (_, rect, child) => _PointerMask(rect: rect, child: child!),
return ValueListenableBuilder<SurveyTouchRegion>(
valueListenable: _touchRegion,
builder: (_, region, child) =>
_PointerMask(region: region, child: child!),
child: webView,
);
},
Expand All @@ -274,7 +279,7 @@ class _SurveyWebViewState extends State<SurveyWebView> {
case CloseEvent():
_closeSurvey();
case GeometryEvent(:final rect):
_cardRect.value = rect;
_touchRegion.value = SurveyTouchRegion.forReported(rect);
case ConsoleEvent(:final log):
Logger.debug('[Console] $log');
}
Expand Down Expand Up @@ -389,7 +394,7 @@ class _SurveyWebViewState extends State<SurveyWebView> {
// presentation via captured handles (no live BuildContext required). The
// guards make removal idempotent if it was already dismissed.
_dismissPresentation();
_cardRect.dispose();
_touchRegion.dispose();
super.dispose();
}

Expand All @@ -406,36 +411,35 @@ bool? _asBool(Object? value) => value is bool ? value : null;
/// (the host app). This is how the SDK achieves RN's `pointerEvents="box-none"`
/// for a full-bleed platform WebView, which otherwise hit-tests its whole area.
class _PointerMask extends SingleChildRenderObjectWidget {
const _PointerMask({required this.rect, required super.child});
const _PointerMask({required this.region, required super.child});

final Rect? rect;
final SurveyTouchRegion region;

@override
_RenderPointerMask createRenderObject(BuildContext context) =>
_RenderPointerMask(rect);
_RenderPointerMask(region);

@override
void updateRenderObject(
BuildContext context,
_RenderPointerMask renderObject,
) {
renderObject.rect = rect;
renderObject.region = region;
}
}

class _RenderPointerMask extends RenderProxyBox {
_RenderPointerMask(this._rect);
_RenderPointerMask(this._region);

Rect? _rect;
set rect(Rect? value) {
if (value == _rect) return;
_rect = value;
SurveyTouchRegion _region;
set region(SurveyTouchRegion value) {
if (value == _region) return;
_region = value;
}

@override
bool hitTest(BoxHitTestResult result, {required Offset position}) {
final rect = _rect;
if (rect == null || !rect.contains(position)) return false;
if (!_region.accepts(position)) return false;
return super.hitTest(result, position: position);
}
}
77 changes: 77 additions & 0 deletions packages/formbricks/test/widgets/survey_touch_region_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import 'dart:ui';

import 'package:flutter_test/flutter_test.dart';
import 'package:formbricks/src/widgets/survey_touch_region.dart';

/// A transparent full-screen WebView still swallows every pointer —
/// `pointer-events: none` is a web hit test Flutter never sees. These pin which
/// pointers the survey claims in each state, because getting it wrong is
/// invisible in review and obvious to a user: either the host app freezes, or
/// the survey itself stops responding.
void main() {
const card = Rect.fromLTWH(0, 600, 390, 240);
const insideCard = Offset(195, 700);
const outsideCard = Offset(195, 200);

group('SurveyTouchRegion', () {
test('an overlaid survey claims every pointer', () {
// A visible backdrop is meant to block the host app.
expect(SurveyTouchRegion.everything.accepts(insideCard), isTrue);
expect(SurveyTouchRegion.everything.accepts(outsideCard), isTrue);
expect(SurveyTouchRegion.everything.accepts(Offset.zero), isTrue);
});

test('a reported rect claims only the card', () {
final region = SurveyTouchRegion.forReported(card);

expect(region, const SurveyTouchRegion.card(card));
expect(
region.accepts(insideCard),
isTrue,
reason: 'the survey must stay usable',
);
expect(
region.accepts(outsideCard),
isFalse,
reason: 'the host app must stay usable',
);
});

test('no card on screen claims nothing', () {
final region = SurveyTouchRegion.forReported(null);

expect(region, same(SurveyTouchRegion.nothing));
expect(region.accepts(insideCard), isFalse);
expect(region.accepts(outsideCard), isFalse);
});

test('the state before any rect arrives blocks, as the SDK always did', () {
// An older self-hosted server serves a renderer that never calls
// `onCardRectChange`, so no rect ever arrives. Absence of a card and
// absence of the feature are different things: conflating them is what
// made the card untappable when the old DOM probe stopped matching.
expect(SurveyTouchRegion.everything.accepts(outsideCard), isTrue);
expect(
SurveyTouchRegion.forReported(null),
isNot(same(SurveyTouchRegion.everything)),
);
});

test('card edges follow Rect.contains', () {
// Rect.contains excludes the far edges, so the bottom-right corner belongs
// to the host app. Pinned because a later inset or rounding change would
// move this silently.
const region = SurveyTouchRegion.card(card);

expect(region.accepts(card.topLeft), isTrue);
expect(region.accepts(card.bottomRight), isFalse);
expect(region.accepts(card.topLeft - const Offset(1, 0)), isFalse);
});

test('a zero-area card claims nothing', () {
const region = SurveyTouchRegion.card(Rect.fromLTWH(10, 10, 0, 0));

expect(region.accepts(const Offset(10, 10)), isFalse);
});
});
}
33 changes: 31 additions & 2 deletions packages/formbricks/test/widgets/survey_webview_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -634,17 +634,46 @@ void main() {
expect(r.host.taps, 1, reason: 'inside-card tap hits the WebView');
});

testWidgets('non-overlay before geometry arrives: every tap passes through',
testWidgets('non-overlay before geometry arrives: the survey takes the tap',
(tester) async {
await _seedConfig();
final r = await presentOver(
tester,
_survey({'id': 's1', 'languages': <dynamic>[]}),
);
// No GeometryEvent yet → nothing is interactive.
// No GeometryEvent yet, which happens for the whole of a survey's life
// against a server whose renderer predates `onCardRectChange`. The SDK has
// to keep taking every pointer, exactly as it did before the mask existed.
//
// This used to assert the opposite — that everything fell through — and
// that is the bug: a survey rendered by an older server was visible but
// completely untappable.
await tester.tapAt(const Offset(50, 50));
await tester.pump();

expect(r.hostTaps(), 0, reason: 'the host must not get the tap');
expect(r.host.taps, 1, reason: 'the survey must stay usable');
});

testWidgets('non-overlay after the card leaves: taps pass through again',
(tester) async {
await _seedConfig();
final r = await presentOver(
tester,
_survey({'id': 's1', 'languages': <dynamic>[]}),
);
r.host.onEvent!(const GeometryEvent(Rect.fromLTWH(0, 0, 100, 100)));
await tester.pump();

// The renderer reports the card's absence while it animates out, a full
// second before the close arrives. Without acting on it the SDK leaves a
// dead patch over a host app that looks perfectly usable.
r.host.onEvent!(const GeometryEvent(null));
await tester.pump();

await tester.tapAt(const Offset(50, 50)); // where the card used to be
await tester.pump();

expect(r.hostTaps(), 1);
expect(r.host.taps, 0);
});
Expand Down
Loading