Skip to content

fix(replay): never copy an app-defined layer's presentation - #1064

Merged
turnipdabeets merged 4 commits into
fix/replay-cleared-content-maskingfrom
fix/replay-app-layer-presentation-guard
Nov 13, 2026
Merged

turnipdabeets merged 4 commits into
fix/replay-cleared-content-maskingfrom
fix/replay-app-layer-presentation-guard

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Follow-up to #1054, and the narrower version of the guard from #1008 discussed in #1054 (comment).

Screenshot replay calls CALayer.presentation(), which copies a layer through its class's init(layer:). A Swift layer subclass that declares its own initializer but not init(layer:) traps there (Use of unimplemented initializer 'init(layer:)'), crashing the host app. That's on main today with screenshotMode, and 4.0 makes screenshots the only mode.

The runtime can't tell a trapping subclass from one that implements init(layer:). Both get their own initWithLayer: from the app binary, and they look identical once stripped. So, like #1008, every layer class whose initWithLayer: comes from neither CALayer nor a system image counts as app-defined, and replay never copies one. Measured on an iOS 27.2 simulator:

Call Copies an app-defined ancestor?
layer.presentation() No, only that layer
Converting a rect from a presentation layer Yes, every ancestor
Rendering the window's presentation tree Yes, every layer
drawHierarchy No

What changes where an app-defined layer is involved:

  • Mask rects: a view under an app-defined layer, or with one of its own, is measured from the model while nothing in its chain animates, so still screens record as before. While something in the chain animates, the mask covers the whole window. fix(replay): don't crash or leak masks on app CALayer subclasses in screenshot replay #1008 masked the whole window even when nothing moved, which blacked out Expo LinearGradient screens.
  • Single-layer reads (opacity, bounds, zPosition, isHidden, replaced image contents) go through readablePresentation. When an app-defined layer is animating and can't be read, the code falls back to whatever keeps content masked.
  • Presentation covers: a cover whose chain holds an app-defined layer isn't treated as a cover, so the masks behind it stay.
  • Motion-band frames, which render the presentation tree, are skipped when the window holds an app-defined layer. Settled and drift frames still draw with drawHierarchy.

Behavior changes (only for apps that use a custom CALayer subclass)

  • Skipped frames during fast scrolling. The motion band covers flings and any capture whose mask owners change between samples, which scrolling lists hit often. Those ticks are skipped and the next tick retries. session-replay-privacy item 11 allows skipping on screen changes. If frozen scrolls get reported, the follow-up is to draw matched-sample frames with drawHierarchy and the drift-inflated rects.
  • Whole-window masks while a masked view's app-defined chain animates. A looping animation in that chain keeps the window masked while it runs. Touches over a ph-no-capture view in such a chain are also dropped for that time.
  • Whole-window masks for an unclipped masked image view with a custom layer class whose content mode doesn't scale the image into its bounds. Its replaced image can't be measured, so the mask reaches as far as unmeasured contents already do in fix(replay): keep cleared labels and replaced images masked until the screen updates #1054.
  • Test fixtures changed. Three PostHogMaskFailClosedTest tests faked in-flight animations with CALayer subclasses that override presentation(). Those now count as app-defined, so the tests hold real animations on plain views instead.

💚 How did you test it?

The simulator runs below used an iOS 27.2 simulator, Xcode 27.2 beta, and IPHONEOS_DEPLOYMENT_TARGET=15.0 overridden on the command line only. Xcode 26.4.1's simulators wouldn't connect locally.

  • New suite PostHogAppLayerPresentationTest (7 tests):
    • It uses an app-defined layer that counts init(layer:) calls.
    • It checks that masking under a still or moving app layer, an animating transparent view, a collapsing clipping view, and a motion frame make zero copies. It also checks that a window of UIKit views keeps its motion frames.
    • The 5 tests that cover the guards fail with them reverted.
  • Your repro's TrapLayer (no init(layer:), above a masked field), as a temporary test:
    • With this change: still → the field's own rect, moving → whole window, motion frame → skipped, and no crash.
    • With the guards reverted: Fatal error: Use of unimplemented initializer 'init(layer:)' for class 'PostHogTests.TrapLayer'.
  • Existing suites:
    • PostHogClearedContentMaskingTest, PostHogMaskFailClosedTest, PostHogMaskingCharacterizationTest, PostHogMaskScenarioTest, PostHogReactNativeMaskingTest and PostHogMaskFlattenedContentTest pass. The one known-limitation test is still recorded as a known issue.
    • The rewritten fail-closed tests fail when the fade and collapse checks are reverted.
    • The hosted PostHogMaskPresentationTest and PostHogMaskPresentationPrivacyTest pass (31 tests).
  • Checks: make lint and make checkTestRegistration pass. The buildSdk builds pass for iOS, macOS, Mac Catalyst, tvOS, watchOS, visionOS and swift build; I ran them without xcpretty, which isn't installed locally. make test fails only in PostHogStorageTest, the app-group container tests, with the same 13 issues on fix(replay): keep cleared labels and replaced images masked until the screen updates #1054's branch, so that's the local environment rather than this change.

Not tested on a physical device: there, system frameworks load from the dyld shared cache, and I haven't confirmed that dladdr still reports /System/Library/ paths for them. If it doesn't, every layer counts as app-defined. That fails closed, but every app would lose motion frames. Also not checked: whether Lottie's and expo-linear-gradient's layer classes count as app-defined in a real 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, at @turnipdabeets's direction, as a handoff for @ioannisj. A probe test settled which calls copy a layer. The classifier is #1008's, since nothing at runtime separates a trapping class from a correct one. Masking from the model while nothing animates is the narrowing over #1008. Two review passes followed: they found a fallback that failed open (isVisibleForMasking on an animating, collapsed, clipping app layer), which is fixed and tested, and the motion-frame cost, which is disclosed above.

🤖 Generated with Claude Code

Copying a layer for presentation() runs its class's init(layer:), which
traps for a Swift CALayer subclass that doesn't implement it. Replay now
measures masks from the model under an app-defined layer while nothing in
its chain animates, masks the whole window while something does, and skips
motion-band frames for windows that hold one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@turnipdabeets
turnipdabeets requested a review from a team as a code owner November 13, 2026 03:59
@turnipdabeets turnipdabeets self-assigned this Nov 13, 2026
@veria-ai

veria-ai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown

PR overview

All previously flagged issues have been addressed. No open security concerns remain on this pull request.

Fixed/addressed: 2 · PR risk: 0/10

Reviews

@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 e15704f: 2 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/UIView+Util.swift Resolved
@github-actions

github-actions Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-11-13T17:13:10.580247+00:00
Duration: 231882ms

⚠️ 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 ✅ 2986ms
Format Validation.Event Has Uuid ✅ 2902ms
Format Validation.Event Has Lib Properties ✅ 2833ms
Format Validation.Distinct Id Is String ✅ 2962ms
Format Validation.Token Is Present ✅ 556ms
Format Validation.Custom Properties Preserved ✅ 2880ms
Format Validation.Event Has Timestamp ✅ 2764ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 2803ms
Retry Behavior.Retries On 503 ✅ 11758ms
Retry Behavior.Does Not Retry On 400 ✅ 4839ms
Retry Behavior.Does Not Retry On 401 ✅ 4874ms
Retry Behavior.Respects Retry After Header ✅ 7873ms
Retry Behavior.Implements Backoff ✅ 15584ms
Retry Behavior.Retries On 500 ✅ 9240ms
Retry Behavior.Retries On 502 ✅ 9350ms
Retry Behavior.Retries On 504 ✅ 9329ms
Retry Behavior.Max Retries Respected ❌ 21848ms
Deduplication.Generates Unique Uuids ✅ 1035ms
Deduplication.Preserves Uuid On Retry ✅ 9370ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 16482ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7941ms
Deduplication.No Duplicate Events In Batch ✅ 3034ms
Deduplication.Different Events Have Different Uuids ✅ 3014ms
Compression.Sends Gzip When Enabled ✅ 2968ms
Batch Format.Uses Proper Batch Structure ✅ 469ms
Batch Format.Flush With No Events Sends Nothing ✅ 304ms
Batch Format.Multiple Events Batched Together ✅ 2992ms
Error Handling.Does Not Retry On 403 ✅ 4824ms
Error Handling.Does Not Retry On 413 ✅ 4834ms
Error Handling.Retries On 408 ✅ 9446ms

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 ✅ 2895ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2837ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2885ms
Request Payload.Flags Request Omits Authorization Header ✅ 2748ms
Request Payload.Token In Flags Body Matches Init ✅ 2880ms
Request Payload.Groups Round Trip ✅ 2881ms
Request Payload.Groups Default To Empty Object ✅ 2866ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2875ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2879ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2834ms
Request Lifecycle.No Flags Request On Init Alone ✅ 97ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 471ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5611ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 2999ms
Retry Behavior.Retries Flags On 502 ✅ 2914ms
Retry Behavior.Retries Flags On 504 ✅ 2989ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 3014ms

@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 82f9484: 1 Must fix.

Comment thread PostHog/Replay/UIView+Util.swift Resolved
Comment thread PostHog/Replay/UIView+Util.swift Resolved

@ioannisj ioannisj 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.

🤖 An AI agent wrote this on behalf of @ioannisj.

No new findings from me. The open thread on failingClosedWhileAnimating is real though: a non-animated move under an app-defined layer gets masked at its new model position while the old one is still on screen, and a test that passes on the base branch fails here. I added the repro details in that thread.

Comment thread PostHog/Replay/UIView+Util.swift Resolved
…r an app-defined layer

Under an app-defined layer, mask rects come from the model, which runs ahead of
what's drawn until Core Animation commits. The bridge and background captures can
measure mid-turn, so a view moved without animation was masked at its new spot
while the screen still showed it at the old one. Flush first when the window
holds an app-defined layer; at the run-loop opportunity there's nothing to commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@turnipdabeets
turnipdabeets merged commit 579b4fa into fix/replay-cleared-content-masking Nov 13, 2026
49 checks passed
@turnipdabeets
turnipdabeets deleted the fix/replay-app-layer-presentation-guard branch November 13, 2026 17:59
@turnipdabeets turnipdabeets mentioned this pull request Nov 13, 2026
6 of 18 tasks
turnipdabeets added a commit that referenced this pull request Nov 14, 2026
… screen updates (#1054)

* fix(replay): keep cleared labels and replaced images masked until the screen updates

* fix(replay): bound replaced-image masks under a fill gravity and keep wireframes on the model image

* fix(replay): mask private image view children and bound replaced images without private layer state

* fix(replay): never copy an app-defined layer's presentation (#1064)

* fix(replay): never copy an app-defined layer's presentation

Copying a layer for presentation() runs its class's init(layer:), which
traps for a Swift CALayer subclass that doesn't implement it. Replay now
measures masks from the model under an app-defined layer while nothing in
its chain animates, masks the whole window while something does, and skips
motion-band frames for windows that hold one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(replay): keep masks behind a cover when an app-defined sibling's z-order can't be read

* fix(replay): mask an unclipped app-defined image view wherever a replaced image could draw

* fix(replay): commit pending layer changes before measuring masks under an app-defined layer

Under an app-defined layer, mask rects come from the model, which runs ahead of
what's drawn until Core Animation commits. The bridge and background captures can
measure mid-turn, so a view moved without animation was masked at its new spot
while the screen still showed it at the old one. Flush first when the window
holds an app-defined layer; at the run-loop opportunity there's nothing to commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Anna Garcia <anna.g@posthog.com>
turnipdabeets added a commit that referenced this pull request Nov 14, 2026
…cated

Brings in #1054 (cleared labels and replaced images stay masked, and the
app-defined CALayer presentation guard from #1064).

- isLabelSensitive keeps 4.0's image-attachment check alongside #1054's
  cleared-text check.
- toWireframe stays removed (4.0 has no wireframe mode); main's
  hasSensitiveImageAttachment is dropped for 4.0's existing one.
- #1054's wireframe-only test is dropped, its fixture uses
  RRScreenshotFrame, and the new replay test files are gated on the
  SessionReplay trait like the other replay suites.
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