Skip to content

fix(replay): mask full-window ph-no-capture views - #970

Merged
turnipdabeets merged 3 commits into
mainfrom
fix/replay-full-window-no-capture
Oct 27, 2026
Merged

turnipdabeets merged 3 commits into
mainfrom
fix/replay-full-window-no-capture

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

A view tagged ph-no-capture that fills the window was not masked itself. Only its descendants were. A full-window custom-drawn view that draws a secret and has one small subview therefore uploaded the secret in the screenshot. This was raised by the bots on #873 (thread 1, thread 2) and deferred to a fix on main.

Decision: an explicit ph-no-capture always masks the view's own rect and its descendants

findMaskableWidgets now masks a tagged view's rect whatever its size, and the full-window exemption is removed. Every tagged view also passes the inherited maskChildren flag down, which used to happen only for full-window ones. The mask uses the view's presentation rect, so an unclipped descendant can draw outside it, for example while the view's bounds animate. The bot review on this PR caught that case. The same rule applies when an ancestor of a presented cover is tagged.

Why the exemption is safe to remove:

  • Why it existed. It came in with recording: fix RN iOS masking #230 (Nov 2024) for React Native. RCTView and RCTViewComponentView answer accessibilityLabel with their descendants' labels joined (RCTRecursiveAccessibilityLabel). A full-screen RN root holding a PostHogMaskView anywhere therefore reported ph-no-capture and would have blacked out the screen.
  • Why that case is gone. Since fix: read accessibilityLabel from parent's view to avoid performance hit on RN #259 (two weeks later), containsAccessibilityToken reads super.accessibilityLabel. That skips the RCTView override and sees only an explicitly assigned label. PostHogAccessibilityTokenCarrierTest already pins this, and a new RN test pins the end-to-end case: a full-window container whose derived label contains the token is not masked, only the PostHogMaskView-style child is.
  • Spec. session-replay-privacy says a no-capture element must be excluded together with its descendants, and that "a masked wrapper's own rect must be included alongside its matching descendants' rects". This change brings iOS in line with it.
  • Android parity. posthog-android masks a ph-no-capture view's visible rect with no size exemption (PostHogReplayIntegration.kt, view.isNoCapture() -> view.addGlobalVisibleRect(walk)).

Behavior changes (both stricter, both intended — please confirm)

  • A full-window ph-no-capture view now records as a fully black frame. That includes a tagged UIWindow, a full-screen tagged root view, and an RN app that wraps its whole screen in PostHogMaskView or sets accessibilityLabel="ph-no-capture" on a full-screen container. Before, only that view's non-full-window descendants were masked, which mostly blacked out the screen anyway.
  • A ph-no-mask view inside a full-window ph-no-capture view is now covered by the parent's rect. This is already what happened for any smaller tagged parent, and it matches Android.
  • A smaller ph-no-capture view now also masks each descendant, as full-window ones always did. Inside the view's rect this changes nothing visible. It only changes unclipped descendants that draw outside the rect, which were uploaded before. Android doesn't do this: it masks the tagged view's rect and stops walking.

v4 compatibility

chore/v4-remove-deprecated changes the same line to || view.subviews.isEmpty, so a full-window leaf is masked. This PR covers that case too. When merging into v4, take this side of the hunk. Also take this PR's expectation for inheritedMaskRespectsScopeAndNoMask, which now includes the parent rect.

💚 How did you test it?

On an iOS 26.4 simulator (iPhone 17):

  • New PostHogMaskFailClosedTest regression tests:
    • A full-window tagged custom-drawn view with a small subview masks window.bounds. On origin/main, only the subview rect was masked.
    • With the model frame at full window and the rendered bounds animating down to 100pt, an unclipped child outside the rendered bounds stays masked. On the first commit of this PR, only the 100pt rect was masked.
  • The fix(replay): preserve masks across siblings and collapse animations #826 inherited-masking tests are kept.
  • New PostHogReactNativeMaskingTest case: a full-window RCTView-like container with a derived label isn't masked whole.
  • Focused mask suites pass: FailClosed, ReactNativeMasking, AccessibilityTokenCarrier, MaskScenario, MaskingCharacterization, MaskFlattenedContent (63 tests).
  • make testPresentationMasks passes (31 tests). That includes noCaptureAncestorAboveCover, which covers the cover-ancestor path.
  • make lint and make buildSdk pass.

What I didn't run:

  • The mask golden snapshots (make maskSnapshots), because the pinned iOS 26.2 runtime isn't installed locally. No golden scenario uses ph-no-capture, so I don't expect them to change, and CI will confirm.
  • A real React Native app.

📝 Checklist

  • All commits are signed (see Commit signing)
  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

  • Built with Claude Code. I checked the history of the exemption (recording: fix RN iOS masking #230, fix: read accessibilityLabel from parent's view to avoid performance hit on RN #259), React Native's RCTRecursiveAccessibilityLabel in RN 0.79 (Paper and Fabric), the sdk-specs replay privacy spec, posthog-android's masking walk, and the posthog.com iOS/RN privacy docs. The docs already say a ph-no-capture view is redacted, so they need no change.
  • The first commit dropped descendant masking, on the theory that the parent rect covers it. The posthog[bot] review showed that a presentation rect smaller than the model frame leaks unclipped children. I reproduced that and restored descendant masking for every tagged view.
  • I ran a pre-PR review loop. Deferred: a second cover-path test, and the v4 merge resolution described above.

🤖 Generated with Claude Code

A view tagged ph-no-capture now always masks its own rect. Previously a tagged view that filled the window was skipped and only its descendants were masked, so its own drawing reached the screenshot.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@turnipdabeets
turnipdabeets requested a review from a team as a code owner October 27, 2026 16:16
@turnipdabeets turnipdabeets self-assigned this Oct 27, 2026

@posthog posthog Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PostHog Review · Standard · reviewed 4b085fc: 1 Must fix.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

Comment thread PostHog/Replay/PostHogReplayIntegration.swift Outdated
@github-actions

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-10-27T22:24:43.478149+00:00
Duration: 230150ms

⚠️ Some Tests Failed

46/47 tests passed, 1 failed


Capture Tests

⚠️ 29/30 tests passed, 1 failed

View Details
Test Status Duration
Format Validation.Event Has Required Fields ✅ 2770ms
Format Validation.Event Has Uuid ✅ 2785ms
Format Validation.Event Has Lib Properties ✅ 2782ms
Format Validation.Distinct Id Is String ✅ 2665ms
Format Validation.Token Is Present ✅ 2770ms
Format Validation.Custom Properties Preserved ✅ 2677ms
Format Validation.Event Has Timestamp ✅ 2817ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 2763ms
Retry Behavior.Retries On 503 ✅ 11795ms
Retry Behavior.Does Not Retry On 400 ✅ 2415ms
Retry Behavior.Does Not Retry On 401 ✅ 4668ms
Retry Behavior.Respects Retry After Header ✅ 7702ms
Retry Behavior.Implements Backoff ✅ 21741ms
Retry Behavior.Retries On 500 ✅ 8759ms
Retry Behavior.Retries On 502 ✅ 9233ms
Retry Behavior.Retries On 504 ✅ 5251ms
Retry Behavior.Max Retries Respected ❌ 21249ms
Deduplication.Generates Unique Uuids ✅ 2861ms
Deduplication.Preserves Uuid On Retry ✅ 9274ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 16735ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7704ms
Deduplication.No Duplicate Events In Batch ✅ 2800ms
Deduplication.Different Events Have Different Uuids ✅ 2759ms
Compression.Sends Gzip When Enabled ✅ 2708ms
Batch Format.Uses Proper Batch Structure ✅ 625ms
Batch Format.Flush With No Events Sends Nothing ✅ 312ms
Batch Format.Multiple Events Batched Together ✅ 2793ms
Error Handling.Does Not Retry On 403 ✅ 4788ms
Error Handling.Does Not Retry On 413 ✅ 4667ms
Error Handling.Retries On 408 ✅ 8714ms

Failures

retry_behavior.max_retries_respected

Expected 4 requests, got 6

Feature_Flags Tests

✅ 17/17 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id ✅ 2809ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2719ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2784ms
Request Payload.Flags Request Omits Authorization Header ✅ 2813ms
Request Payload.Token In Flags Body Matches Init ✅ 2734ms
Request Payload.Groups Round Trip ✅ 2766ms
Request Payload.Groups Default To Empty Object ✅ 2715ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2794ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2758ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2816ms
Request Lifecycle.No Flags Request On Init Alone ✅ 60ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 2722ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5386ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 2748ms
Retry Behavior.Retries Flags On 502 ✅ 2770ms
Retry Behavior.Retries Flags On 504 ✅ 2754ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 2953ms

A tagged view's mask is its presentation rect, so an unclipped descendant drawing outside it (for example while the view's bounds animate) leaked. A tagged view now masks its own rect and every descendant, whatever its size, and the tests that pin descendant masking are restored.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The previous descendant-masking issue is resolved. One optional mixed UIKit/SwiftUI mask-source overlap is noted inline.

AI-assisted review.

Comment thread PostHog/Replay/PostHogReplayIntegration.swift
The native walk and the mask registry both collected it, and the repeated
owner made the settle check read a still frame as motion.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
@turnipdabeets
turnipdabeets enabled auto-merge (squash) October 27, 2026 22:25
@turnipdabeets
turnipdabeets enabled auto-merge (squash) October 27, 2026 22:33
turnipdabeets added a commit that referenced this pull request Oct 27, 2026
… guide (#980)

#970 masks a full-window ph-no-capture view and its descendants, so the
exception no longer applies.

Co-authored-by: Claude Opus 5.5 <[email protected]>
@turnipdabeets
turnipdabeets merged commit 707d3b2 into main Oct 27, 2026
50 checks passed
@turnipdabeets
turnipdabeets deleted the fix/replay-full-window-no-capture branch October 27, 2026 22:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants