feat(replay): PostHogMaskWidget enables web canvas masking on its own - #501
Conversation
posthog-flutter Compliance ReportDate: 2026-07-30 14:42:47 UTC ✅ All Tests Passed!45/45 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 16/16 tests passed View Details
|
5c00fc8 to
93e3d0d
Compare
688652a to
035e3f1
Compare
93e3d0d to
8ba79e4
Compare
035e3f1 to
2010638
Compare
8ba79e4 to
393cdd5
Compare
2010638 to
01e3715
Compare
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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HPiKky15SKqNg9PT2wHeaw
393cdd5 to
fc3a116
Compare
…register-fixes # Conflicts: # posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart # posthog_flutter/test/web_canvas_mask_provider_test.dart
… document both opt-in paths 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 <noreply@anthropic.com>
…g.canvasCapture.maskRegionsFn Also renames the old path in this branch's own additions (mount opt-in warnings, dartdoc, changeset, tests). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…vas stills Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves the provider conflict by keeping the mount state machine alongside the version gate, and adds a test that the warning also fires on the mount-triggered apply path. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
🦔 ReviewHog reviewed this pull requestFound 0 must fix, 1 should fix, 1 consider. Published 2 findings (view the review). |
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.
|
ReviewHog Alpha 🦔 If you find any issues helpful - please reply "valid", "invalid", etc., for evaluation purposes 🙏 |
There was a problem hiding this comment.
ReviewHog Report
Changes
Issues: 1 issue
Files (7)
.changeset/canvas-masking-web.md.changeset/mask-widget-web-canvas.mdexample/lib/masking_tests_screen.dartposthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dartposthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dartposthog_flutter/lib/src/replay/mask/posthog_mask_widget.dartposthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
Other findings (outside the changed lines)
Valid issues on this PR's files that sit on lines GitHub won't let us comment on inline.
Retry loop never backs off when _apply() keeps throwing, unlike the posthog-not-ready path
Priority: consider | File: posthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart:161-178 | Category: performance
Why we think it's a valid issue
- Checked: Traced
_scheduleRetry(L164-181) and every_apply()throw source (set_configL248,stopSessionRecording/startSessionRecordingL255-256), plus the paths that enter the chain with an exception in flight (_onMaskWidgetMountedcatch L140-141,registercatch L120-121). - Found: The code claim is accurate:
nextis seeded todelay(L166) and only ramped inside thetryon aposthogNotReadyresult (L172-175); a thrown_apply()skips tocatch(L176-178) leavingnext == delay, so_scheduleRetry(next)(L179) reschedules at the same interval. TheposthogNotReadypath deliberately backs off to a 4s ceiling (comment L161-163: 'polls forever once backed off to 4s'); the exception path does not. - Found (reachability): A true infinite fixed-250ms loop requires
set_configorstopSessionRecordingto throw on every tick — if onlystartSessionRecordingthrows, the next tick seessessionRecordingStarted()==false(L254), skips the restart, sets_applied=true(L260), and the chain terminates in ~2 ticks. - Impact: Confirmed but bounded: under a persistent
set_config/stopSessionRecordingthrow the chain re-runs the apply at 4Hz for the page's life, producingprintIfDebugspam (L177) and wasted interop calls; recording is left running in both loop cases (no crash/hang/data-loss), and it only bites in an already-degenerate posthog-js state. - Priority: Real, verified inconsistency with a one-line fix, but a rare/degenerate trigger and bounded (log-spam / wasted-cycles) impact — real-but-minor, so down-ranked from should_fix to consider rather than surfaced at higher urgency.
Issue description
This chunk adds a new, expected way to enter the shared _scheduleRetry chain with a genuine exception in flight: _onMaskWidgetMounted()'s catch block explicitly schedules a retry (_polling = true; _scheduleRetry(const Duration(milliseconds: 250));) when _apply() throws mid-way (e.g. set_config or stopSessionRecording/startSessionRecording failing). But _scheduleRetry's Timer callback only advances the backoff delay on the success/not-ready branch: next is initialized to the current delay and is only reassigned to doubled/capped-at-4s inside the try block, right before the early return. When _apply() throws again on that retry tick, execution jumps straight to catch (e) { printIfDebug(...); }, next is never touched, and _scheduleRetry(next) reschedules at the exact same delay it started with. Contrast this with the posthogNotReady path, which the adjacent comment explicitly says 'polls forever once backed off to 4s' to avoid hammering anything while waiting for a slow app boot — that reasoning was never extended to the exception path this PR newly makes reachable. If an app hits a persistent failure after a PostHogMaskWidget mounts (e.g. stopSessionRecording()/startSessionRecording() throwing every time due to a broken posthog-js state, a hostile monkey-patch, or a CSP/extension interference that's not transient), the SDK will retry the full _apply() — including set_config and a stopSessionRecording/startSessionRecording pair — every 250ms for the rest of the page session, since nothing ever raises the delay past its starting value. None of the existing tests ('an exception during one retry tick does not kill the chain' and 'retries the full apply when the restart throws on first register') exercise a repeated/persistent throw — both stub the failure to occur exactly once and succeed on the second attempt, so this fixed-interval-forever behavior has no test coverage and was not caught by any prior pass.
Suggested fix
Compute the backed-off next delay unconditionally (before or independent of the try/catch), so a repeated exception still ramps the interval up toward the same 4s ceiling used for the not-ready case, e.g. move the doubled/next calculation above the try, or wrap only the _apply() call itself in the try and always fall through to the backoff computation afterward.
…PostHogWidget tree
…en check, foreign-view fail-closed, parser refresh, restart retry
|
valid — the "retry loop never backs off when _apply() keeps throwing" finding was correct (confirmed the exception path reused the incoming delay while the posthog-not-ready path ramped to 4s, and the same structure existed on the base branch #499). Fixed in 017c8a1: the backoff now computes up front so both paths follow the same 250ms→4s curve, with a regression test discriminating the doubling chain from fixed-rate retries by attempt count. Nice catch on the reachability analysis too — it correctly bounded the impact to log spam rather than data loss. |
…the ordering Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
marandaneto
left a comment
There was a problem hiding this comment.
left a last comment, approving to unblock
One feature, one entry — the stacked PR merges into its base before release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…sed 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 <noreply@anthropic.com>
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.
There was a problem hiding this comment.
ReviewHog Report
Changes
Issues: 2 issues
Files (7)
.changeset/canvas-masking-web.md.changeset/mask-widget-web-canvas.mdexample/lib/masking_tests_screen.dartposthog_flutter/lib/src/replay/mask/canvas_mask_registration_io.dartposthog_flutter/lib/src/replay/mask/canvas_mask_registration_web.dartposthog_flutter/lib/src/replay/mask/posthog_mask_widget.dartposthog_flutter/lib/src/replay/web/web_canvas_mask_provider.dart
…oes; guard the mount callback 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 <noreply@anthropic.com>
) * feat(replay): mask canvas session replay recordings on Flutter web On Flutter web (CanvasKit) session replay is recorded by posthog-js canvas capture, and DOM-based masking cannot reach text painted into the canvas — sessionReplayConfig masking options were silent no-ops (#496). setup() now registers a mask-region provider with posthog-js (session_recording.captureCanvas.canvasMaskRegionsFn): widget-tree rects are computed with the same selection logic mobile uses (maskAllTexts / maskAllImages / PostHogMaskWidget / obscured text fields), converted to canvas-relative CSS pixels (transform-aware, 1px outset, per-Flutter-frame cache), and painted black inside the posthog-js capture pipeline before frames are encoded. Fails closed: a failed widget-tree walk yields a full-canvas mask, and with requireMaskProvider set in the posthog.init HTML config, frames captured before Flutter registers are blacked out. The flt-semantics accessibility tree (which mirrors widget text into recordable DOM) is excluded via blockSelector, and an in-flight recording is restarted once so start-time options apply. Registration retries with backoff until posthog-js is available — covering both the snippet stub being replaced by the real instance and posthog-js loading after Flutter entirely. Requires posthog-js with captureCanvas.canvasMaskRegionsFn support and config.sessionReplay = true. Fixes #496 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(replay): keep retrying web canvas mask registration until posthog.init runs posthog-js constructs its instance with a default config before init(), so a present config no longer counts as initialized — only __loaded does. The retry chain now polls indefinitely at the 4s backoff cap instead of giving up after 2 minutes, so consent-gated apps that init late still get masking, and an exception during a retry tick reschedules instead of killing the chain. Also latch the frame-callback flag only after registration succeeds, and cancel live retry chains between tests. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(replay): follow posthog-js rename to session_recording.canvasCapture.maskRegionsFn Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(changeset): correct the rr_dataURL full-snapshot note — CanvasKit canvases are readable there Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(replay): warn once when posthog-js is too old to mask canvas frames Registration still proceeds on an old posthog-js — the blockSelector accessibility-DOM exclusion works there — but canvas frames ship unmasked, so emit one console.warn when the detected version is confirmed older than the minimum. Absent or unparseable versions are assumed new so the gate cannot misfire on custom bundles or future version schemes. The minimum is a '0.0.0' placeholder until the first posthog-js release with canvasCapture.maskRegionsFn support exists to pin against. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(replay): cancel the predecessor provider's retry chain on register A second Posthog().setup() left the first provider's retry timer polling; once posthog-js appeared, both chains applied config and restarted the recording twice. * fix(replay): retry registration when the first apply throws mid-way A restart that throws after set_config landed used to end the chain in register()'s catch, leaving blockSelector unapplied until a natural recording restart. * fix(replay): honor maskAllTexts=false for Text widgets in the shared masking walk * fix(replay): address review — container transform, exact blockSelector token, foreign-view fail-closed, parser refresh, restart retry * docs(changeset): trim the canvas-masking changeset to the essentials * fix(replay): back off retries when the apply keeps throwing The exception path reused the incoming delay, retrying a persistently failing apply at a fixed 250ms for the page's life; it now ramps to the same 4s ceiling as the posthog-not-ready path. * docs(changeset): note multi-view foreign Flutter canvases are skipped Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore(replay): pin the posthog-js minimum to 1.408.0 (ships maskRegionsFn) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(changeset): rewrite entries per changelog style — one-line, user-facing Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(replay): fail closed when multiple Flutter views share one host element In full-page mode the embedder host is <body>; with a second engine on the page, containment matched a foreign view's canvas and paired it with our rects. Skip frames for every canvas when the host holds more than one flutter-view, since ownership cannot be proven. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * feat(replay): PostHogMaskWidget enables web canvas masking on its own (#501) * feat(replay): PostHogMaskWidget enables web canvas masking on its own 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) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HPiKky15SKqNg9PT2wHeaw * fix(replay): address review — survive a failed mount-triggered apply, document both opt-in paths 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 <noreply@anthropic.com> * docs(changeset): note pre-mount full snapshots can embed unmasked canvas stills Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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. * fix(replay): only opt in from a PostHogMaskWidget inside the tracked PostHogWidget tree * fix(replay): enforce a single retry chain; cover tracked-tree mount gating * docs(replay): scope the outside-tree opt-in claim to mount time; pin the ordering Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(changeset): rewrite the mask-widget entry per changelog style Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs(changeset): fold the mask-widget entry into the feature changeset One feature, one entry — the stacked PR merges into its base before release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * 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 <noreply@anthropic.com> * 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. * fix(replay): resolve the tracked-tree root the way the masking walk does; guard the mount callback 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 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(replay): harden canvas masking edge paths from review Fail closed on non-finite regions from singular ancestor transforms and on the shadow-DOM-blind no-host fallback; check canvas ownership before walk accounting so foreign canvases don't advance the failure counter; drop zero-size rects before the 1px outset; document the recordCanvas requirement in the mask widget's web snippet. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
💡 Motivation and Context
#499 gives Flutter web canvas masking, but it only registers the mask-region provider with posthog-js when the app already declared
session_recording.canvasCapture.maskRegionsFnin itsposthog.initcall.That leaves a footgun: a developer wraps sensitive UI in
PostHogMaskWidget, does not also editweb/index.html, and gets zero masking on web, silently.PostHogMaskWidgetis the most explicit possible statement of "never record this", and it needs no setup at all on iOS and Android — so web quietly diverging is the worst possible failure mode.The gate itself is not wrong. Registering does two things beyond attaching a function: it adds
blockSelector: 'flt-semantics-host'(so Flutter's accessibility DOM is not recorded in the clear), and it restarts an in-flight recording, because posthog-js only reads those options when rrweb'srecord()starts. Imposing that on every Flutter web app — including ones that never asked for masking — is not acceptable.So this PR adds a second way to opt in rather than removing the gate: a mounted
PostHogMaskWidgetis itself the opt-in.🔨 What changed
PostHogMaskWidgetState.initStatecallsnotifyMaskWidgetMounted(), resolved through a conditional import (canvas_mask_registration_io.dart/..._web.dart). Off web it is an empty function body, so no web code reaches mobile builds and nothing changes on iOS/Android/macOS.WebCanvasMaskProvider.notifyMaskWidgetMounted(). Deferring matters: registration restarts the recording, and rrweb can take a canvas snapshot from inside that call — which would walk a widget tree that is still being built.WebCanvasMaskProviderkeeps the same "did the app declare it?" gate, but now also applies when a mask widget has mounted. The retry/apply path became a small state machine (applied/notOptedIn/posthogNotReady) so that "posthog-js is up but the app did not opt in" parks instead of terminating — aPostHogMaskWidgetmounting later re-enters it. Registration is idempotent: at most oneset_configand at most one recording restart, however many mask widgets mount, and whether posthog-js loads before or after Flutter.Apps with no
PostHogMaskWidgetand no declaration are still completely untouched: noset_config, noblockSelector, no restart.Also drops a stale caveat from the example app: test 15's section title claimed
PostHogMaskWidgetwith multiple children "needs maskAllTexts or maskAllImages". It does not, on either platform —getMaskElementscallsextractMaskWidgetRects()unconditionally on web, andscreenshot_capturercallsgetPostHogWidgetWrapperElements()outside the flags branch on mobile (post-#500). A browser check against the built app onfeat/web-canvas-maskingwithmaskAllTexts: false, maskAllImages: falseconfirmed it: the provider returned a full-width rect covering the whole Row, amber box included.These are documented in the CHANGELOG and in the
PostHogMaskWidgetdartdoc:maskAllTexts/maskAllImagesdefault to true, so a singlePostHogMaskWidgetturns on full text and image canvas masking — the same semantics as mounting one on iOS/Android.blockSelectoris a start-time option.maskRegionsFn: () => nullinposthog.initis still the only thing that covers the window between page load and Flutter booting (those frames are skipped instead), and it also avoids the restart. The HTML setup is now an optimization, not a requirement.PostHogWidgetis still required. An app that mounts aPostHogMaskWidgetbut is not wrapped inPostHogWidgetnow fails closed — canvas frames are skipped rather than recorded unmasked, with a console warning explaining the fix. That is the correct behavior for something that explicitly asked for masking, but it is a behavior change for that (misconfigured) shape of app. The outside-tree check runs once, at the mask widget's first mount: a mask widget that mounts before anyPostHogWidgetexists is treated as this no-PostHogWidgetshape and does opt in (documented in the changeset, pinned by a test).By design: registration does not wait on
recordCanvasRegistration opts in without first checking that canvas capture is actually enabled. That is deliberate, for two reasons:
recordCanvascan arrive from remote config afterposthog.init, so aph.configcheck at registration time can read "canvas off" for a session that goes on to record the canvas — masking would silently never attach.blockSelector: 'flt-semantics-host', and that exclusion is independent of the canvas: with accessibility active, Flutter mirrors widget text intoflt-semanticsDOM nodes, which rrweb records as plaintext during ordinary DOM recording. So a canvas-off app that mounts aPostHogMaskWidgetstill gains a real thing — the a11y text side-channel is closed.The restart is gated on
ph.sessionRecordingStarted()regardless, so an app that is not recording pays nothing at all.💚 How did you test it?
Three new tests in
test/web_canvas_mask_provider_test.dart(already listed in the "Test (web)" CI job, so no workflow change needed):PostHogMaskWidgetopts the app in whenposthog.initdeclared nothing — provider attached,blockSelectorset, recording restarted once;set_configand one restart across two pumps with three and then five mask widgets;A follow-up commit (review fixes, merged with the updated #499 base) adds two more: cross-path idempotency (app opted in via the init declaration, then a mask widget mounts — still exactly one
set_configand one restart), and aset_configthrow during the mount-triggered apply being retried and applied on a later tick instead of permanently consuming the opt-in.The pre-existing
leaves posthog-js untouched when the app declares no mask providertest is unchanged and still passing, which is the assertion that apps opting into nothing stay untouched.Ran locally, all green:
flutter test— 247 passingflutter test --platform chrome test/posthog_flutter_web_handler_test.dart test/web_canvas_mask_provider_test.dart— 46 passingflutter analyze— no issuesdart format --output=none --set-exit-if-changed ./— exit 0The registration change itself was not manually verified in a browser against a real posthog-js build; the posthog-js side of the contract is exercised by the stub, as in #499. The test 15 measurement quoted above was taken by hand on the base branch, not by the agent.
📝 Checklist
If releasing new changes
.changeset/mask-widget-web-canvas.md) per the changelog style; the behavioral detail (whole-config opt-in, one-time restart, pre-mount window,PostHogWidgetrequirement, mount-time outside-tree check) lives in this PR body and thePostHogMaskWidgetdartdoc.🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Written with Claude Code (Opus 5). The design question was how to let shared widget code trigger web-only registration without leaking
dart:js_interopinto mobile builds; the repo's existing_io.dart/_web.dartconditional-import convention (seeorigin.dart,chunk_ids.dart) handles it, so the mount hook is a one-line no-op function on every non-web platform.Two alternatives were rejected: removing the gate outright (would restart recording and drop the semantics tree for every Flutter web app, including ones that never asked for masking), and gating registration on
recordCanvasbeing visibly enabled inph.config(fails open when canvas capture arrives via remote config, and would forfeit the semantics exclusion for canvas-off apps — see "By design" above).Deferring the hook to a post-frame callback was a deliberate change after the first working version called it synchronously from
initState— registering synchronously means posthog-js may re-enter the mask-region provider mid-build, which walks the render tree.