diff --git a/packages/formbricks/lib/src/widgets/survey_html.dart b/packages/formbricks/lib/src/widgets/survey_html.dart index 873fe90..19975d9 100644 --- a/packages/formbricks/lib/src/widgets/survey_html.dart +++ b/packages/formbricks/lib/src/widgets/survey_html.dart @@ -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) { @@ -228,6 +186,7 @@ String buildSurveyHtml(SurveyHtmlOptions options) { onResponseCreated, onFinished, onClose, + onCardRectChange, getSetIsResponseSendingFinished, getSetIsError, }; @@ -238,8 +197,6 @@ String buildSurveyHtml(SurveyHtmlOptions options) { return; } runtime.renderSurvey(surveyProps); - fbObserveGeometry(); - fbEnsureGeometryLoop(); } catch (error) { closeOnError('Failed to render Formbricks survey:', error); } diff --git a/packages/formbricks/lib/src/widgets/survey_touch_region.dart b/packages/formbricks/lib/src/widgets/survey_touch_region.dart new file mode 100644 index 0000000..2dec856 --- /dev/null +++ b/packages/formbricks/lib/src/widgets/survey_touch_region.dart @@ -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; +} diff --git a/packages/formbricks/lib/src/widgets/survey_webview.dart b/packages/formbricks/lib/src/widgets/survey_webview.dart index 4049bd5..c51bdfa 100644 --- a/packages/formbricks/lib/src/widgets/survey_webview.dart +++ b/packages/formbricks/lib/src/widgets/survey_webview.dart @@ -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'; @@ -75,7 +76,10 @@ class _SurveyWebViewState extends State { // placements keep the full-screen modal route (the backdrop *should* block). OverlayEntry? _overlayEntry; bool _hasOverlay = false; - final ValueNotifier _cardRect = ValueNotifier(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 _touchRegion = + ValueNotifier(SurveyTouchRegion.everything); // Serializes config read-modify-writes so back-to-back events (e.g. response // then close) can't clobber each other. @@ -249,9 +253,10 @@ class _SurveyWebViewState extends State { // 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( - valueListenable: _cardRect, - builder: (_, rect, child) => _PointerMask(rect: rect, child: child!), + return ValueListenableBuilder( + valueListenable: _touchRegion, + builder: (_, region, child) => + _PointerMask(region: region, child: child!), child: webView, ); }, @@ -274,7 +279,7 @@ class _SurveyWebViewState extends State { case CloseEvent(): _closeSurvey(); case GeometryEvent(:final rect): - _cardRect.value = rect; + _touchRegion.value = SurveyTouchRegion.forReported(rect); case ConsoleEvent(:final log): Logger.debug('[Console] $log'); } @@ -389,7 +394,7 @@ class _SurveyWebViewState extends State { // 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(); } @@ -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); } } diff --git a/packages/formbricks/test/widgets/survey_touch_region_test.dart b/packages/formbricks/test/widgets/survey_touch_region_test.dart new file mode 100644 index 0000000..d55e2b8 --- /dev/null +++ b/packages/formbricks/test/widgets/survey_touch_region_test.dart @@ -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); + }); + }); +} diff --git a/packages/formbricks/test/widgets/survey_webview_test.dart b/packages/formbricks/test/widgets/survey_webview_test.dart index 6691a2f..d187581 100644 --- a/packages/formbricks/test/widgets/survey_webview_test.dart +++ b/packages/formbricks/test/widgets/survey_webview_test.dart @@ -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': []}), ); - // 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': []}), + ); + 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); });