Skip to content

feat: add experimental GPU screenshot capture for session replay - #911

Merged
turnipdabeets merged 31 commits into
mainfrom
feat/replay-gpu-mirror-capture
Nov 13, 2026
Merged

turnipdabeets merged 31 commits into
mainfrom
feat/replay-gpu-mirror-capture

Conversation

@ioannisj

@ioannisj ioannisj commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Screenshot-mode session replay captures with drawHierarchy on the main thread. On an iPhone 17 Pro that blocks the main thread for about 60 ms per capture, several frames at 120 Hz, every time a capture runs.

Adds an experimental, opt-in GPU capture path that keeps capture on the main thread but splits it into short steps that each fit well inside a frame.

Reviewing this PR

Masking on this path is reviewed in #966, which is stacked on this branch. GPU capture masks inline: #966 redacts the copied layer tree itself before it renders, instead of painting rects over the finished frame. Here the GPU path still paints the default path's mask rects over the read-back frame, as a stopgap that #966 replaces. Masking questions about GPU capture (what is masked, where, and how it lines up) are best raised there.

Review here is best spent on:

  • whether the copy matches the screen: the layer mirror, the private keys it copies, portals, and the Liquid Glass stand-ins
  • the capture flow: the two-turn split, the commit at the start of turn 1, what is pinned and when, and the off-main teardown
  • host safety: an app CALayer subclass is never copied by the mirror, and every failure falls back to the existing path
  • cost: main-thread time per step and the memory held while recording
  • the default path staying as it is on main (see below)

screenshotModeGPUCapture (experimental, iOS and Mac Catalyst, off by default)

Copies the window's presentation layer tree into plain layers that share the app's existing backing stores, so nothing is re-rasterised. A cached CARenderer renders that copy into a Metal texture, and the pixels are read back off the main thread.

Each capture commits the app's pending changes before it copies, so the frame is built from one committed layer state and the settle-and-recheck step isn't needed on this path. A label or image changed in the same turn is captured in its new state, with none of the old pixels.

The main-thread work is split across two run-loop turns:

  • the first commits, builds the copy, walks masks and commits the copy
  • the second encodes the GPU render

The copy's commit has to stay in the first turn: it pins each shared backing store, so a redraw after it can't reach the frame. The copied layer tree is torn down off the main thread after each capture, and cached renderers are freed on uninstall() and on memory warnings.

App and framework CALayer subclasses get one rule in the mirror. Their presentation can't be copied safely (a subclass without init(layer:) traps), so they're drawn from their committed model state:

  • a static one is drawn as is, since its model matches what's on screen
  • one with a geometric animation is left out of the frame with its subtree while the animation runs (opacity and background colour animations don't count)
  • if that leaves out the full-window cover or one of its ancestors, the frame falls back to the existing path instead of being skipped

The practical cost is that a playing Lottie animation (Core Animation engine) or any app-class layer mid-animation is missing from GPU frames while it moves.

Layers that can't add pixels are skipped too (hidden, transparent, clipped away, or far off-screen). The renderer warms up on a background queue.

On iOS 26 and later some of what UIKit draws isn't in the public layer properties, so:

  • per-corner and capsule (NaN) corner radii, and image contents transform, tint and swizzle, are copied from their private keys
  • SwiftUI images (_SwiftUIProxyImage contents) are shared with the renderer
  • portal layers (SF Symbol effects, lens views, iOS 27 glass bar buttons) are drawn when they show their source in place or elsewhere. Portals nested inside another portal's source, under an app-class layer, or under a layer that transforms its sublayers are left out
  • Liquid Glass can't be rendered by CARenderer, so it's drawn as a stand-in:
    • controls and bars get a flat fill in their own shape, tinted from the glass material and matched to light and dark mode
    • sheets, alerts and dialogs get a public UIVisualEffectView material (.systemThinMaterialLight / .systemThinMaterialDark), so text behind them stays blurred like on screen. No CAFilter or CABackdropLayer is created

What it doesn't capture:

  • Metal content (MapKit, SceneKit, SpriteKit, MTKView) shows as a striped "Metal content" placeholder, capped at 1024 px
  • video and camera previews stay blank, same as today
  • Liquid Glass refraction and highlights, since the glass is an approximation
  • app-class layers while they animate, as above

If Metal is unavailable or a render fails (including a blank readback), that frame falls back to the existing path. While recording, it keeps roughly 25 MB more memory resident on average (GPU memory held by Metal and Core Animation in the app's process); peak memory is about the same. That was measured with screenshotScale unset, one pixel per point, and a larger scale holds more. It's documented on the flag.

screenshotScale

GPU capture uses the screenshotScale option from #994 through the same resolver as the default path, so both paths upload the same size for the same config:

  • unset: one pixel per point (402×874 on an iPhone 17 Pro)
  • a value: that fraction of native resolution, so 0.5 is 1.5 pixels per point on a 3x device and 1.0 is native

GPU frames render at that size directly, so nothing is resampled afterwards. This PR doesn't add a scale option of its own.

The default path

The default path behaves as it does on main, with one exception: a mask over a layer with a shadow covers the larger of the model's and the presented shadow. That can only grow a mask.

The rest of what the shared files change does nothing unless GPU capture is on: the renderer choice is an enum instead of a Bool, the shared mask walk takes parameters that default to empty, captures start through one entry point, and the GPU lifecycle hooks return early.

Two things this branch used to carry on the default path are out, and live in their own PRs:

One consequence on the GPU path: mask measurement still reads presentation layers the way main does, so a masked view under a layer whose copy traps would trap there, as it does on main today.

Spec note

The session-replay-privacy spec says mask discovery must traverse the full matched subtree. On the GPU path the walk skips subtrees the render left out of the frame, which includes app-class layers while they animate. Those have no pixels in the uploaded frame, so it fails closed, but it's a wording divergence worth knowing about.

💚 How did you test it?

PostHogGPUCaptureTest covers the mirror and the capture flow: the culled-subtree skip rule, app-class layers (static, animating, moved this turn, under portals, as cover ancestors, and a CALayer subclass that traps on copy and must never be copied), a just-cleared label and a just-cleared or just-replaced image uploading none of their old pixels, the settled fallback uploading a frame, the Metal placeholder and its size cap, state changes between the two turns, flag routing, uploaded sizes matching the default path for unset, 0.5 and 1.0 screenshotScale, the copied layer tree and cached renderers being released, corner radii, contents keys, portals, the glass stand-ins in light and dark mode, and failed or blank renders falling back.

On the iOS 27.2 simulator the ten replay capture and masking suites pass (201 tests, with the one known-limitation test still recorded as a known issue), the macOS suite passes (1184 tests) and lint passes. PostHogMaskFailClosedTest is back to main's version.

Performance. iPhone 17 Pro (iOS 26.7.2, 120 Hz), Release builds of main (986df1a) and this branch (5af7451) with the same instrumentation, a constantly scrolling SwiftUI list, 3 interleaved rounds of 30 s each, replay on in both, screenshotScale unset:

main GPU capture
Longest main-thread step per capture, median / p95 42.1 / 45.4 ms 5.2 / 5.6 ms
Steps longer than a 120 Hz frame (8.33 ms) 90 of 180 3 of 267
Capture loop, trigger to enqueued, median 139.5 ms 52.2 ms
Background work per capture, median 69.7 ms 41.2 ms
Memory, average / peak 38.7 / 69.7 MB 63.2 / 74.4 MB

The three steps over a frame are all in the first capture of a run, while the renderer is still cold: 16.6 ms and 65.7 ms in one round, 8.4 ms in another. After the first capture, 0 of 258 steps go over a frame and the longest turn 1 is 5.7 ms.

GPU capture's main-thread steps, median: turn 1 5.2 ms, turn 2 (encode) 3.0 ms, release 0.2 ms. Turn 1 is the mirror build (3.9 ms) and the mask walk (1.2 ms); committing the app's pending changes and committing the copy cost under 0.1 ms each on this screen. The render takes about 1.7 ms on the GPU and the readback 1.8 ms, both off the main thread.

Every frame in the GPU runs came from the GPU path (89 of 89, no fallbacks), on an "Apple A19 Pro GPU". With the flag off, this branch matches main: 42.7 ms longest step and a 140.8 ms loop.

Rendering. I compared both paths against a simulator screenshot of what the user sees, across matched UIKit and SwiftUI screens (every button style, controls, lists, nav bars, toolbars, tab bars, sheets, alerts, action sheets) in light and dark mode, on iOS 26.5 and 18.6. On iOS 26.5 the median difference from the screen is 0.08 for drawHierarchy and 0.59 for GPU capture (0 to 255 scale). On 18.6 both are close to exact. GPU capture differs mostly on Liquid Glass panels. The masked scenarios in these sheets show this branch's stopgap rects, not how GPU capture masks once #966 lands.

contact-bars contact-presentations contact-swiftui-controls contact-uikit-controls

iOS 27.2. Same comparison on an iPhone 18 Pro simulator with Xcode 27: median difference from the screen 0.15 for drawHierarchy, 0.77 for GPU capture.

The run found two 27-only regressions, fixed in 325d935. Glass bar buttons (toolbar, nav bar, search field) drew without their text and icons: iOS 27 shows each button through a portal whose bounds start at the button's position, and portalContent pinned the source to that origin. Clear glass took the tinted fill: its sampling margin and output range on 27 are 0.53 and 1.53 rather than 0 and 1.

Still open on 27: sheets, alerts and dialogs draw as a flat panel instead of the blurred material (GPU mean up to 38 on the dark SwiftUI sheet, 6 on iOS 26). That goes to a follow-up PR after this one, since the flag is experimental and off by default.

contact-bars contact-presentations contact-swiftui-controls contact-uikit-controls

A 15-minute device soak with GPU capture on stays flat at 63.5 to 63.6 MB after start-up, so the extra memory is a fixed cost (the cached renderer and its render targets), not growth per capture.

I also found a separate, existing bug while testing: PostHogGraphicsImageRenderer allocates its bitmap with malloc and never clears it, so a failed or partly transparent draw can upload leftover memory. That's not changed here; it was fixed separately in #999.

📝 Checklist

  • 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

DRI: @ioannisj
Autonomy: Human-driven (agent-assisted)

@ioannisj ioannisj self-assigned this Oct 6, 2026
@greptile-apps

greptile-apps Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Retrigger

[Medium risk] Adds GPU-based screenshot capture and scaling to session replay.

The PR should not merge until GPU capture preserves restrictive layer masks and prevents frames crossing session boundaries.

Reviews (1) · Last reviewed commit: "feat: default screenshotScale to 0.75"

@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-11-13T19:34:23.288951+00:00
Duration: 237460ms

⚠️ 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 ✅ 2830ms
Format Validation.Event Has Uuid ✅ 2773ms
Format Validation.Event Has Lib Properties ✅ 2650ms
Format Validation.Distinct Id Is String ✅ 2755ms
Format Validation.Token Is Present ✅ 2692ms
Format Validation.Custom Properties Preserved ✅ 2663ms
Format Validation.Event Has Timestamp ✅ 2705ms
Format Validation.Non Utc Event Timestamp Is Converted To Utc ✅ 2730ms
Retry Behavior.Retries On 503 ✅ 11288ms
Retry Behavior.Does Not Retry On 400 ✅ 4750ms
Retry Behavior.Does Not Retry On 401 ✅ 4748ms
Retry Behavior.Respects Retry After Header ✅ 5251ms
Retry Behavior.Implements Backoff ✅ 21837ms
Retry Behavior.Retries On 500 ✅ 9099ms
Retry Behavior.Retries On 502 ✅ 9306ms
Retry Behavior.Retries On 504 ✅ 9265ms
Retry Behavior.Max Retries Respected ❌ 21733ms
Deduplication.Generates Unique Uuids ✅ 2859ms
Deduplication.Preserves Uuid On Retry ✅ 9142ms
Deduplication.Preserves Uuid And Timestamp On Retry ✅ 16278ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry ✅ 7912ms
Deduplication.No Duplicate Events In Batch ✅ 2718ms
Deduplication.Different Events Have Different Uuids ✅ 3323ms
Compression.Sends Gzip When Enabled ✅ 2765ms
Batch Format.Uses Proper Batch Structure ✅ 2732ms
Batch Format.Flush With No Events Sends Nothing ✅ 314ms
Batch Format.Multiple Events Batched Together ✅ 2850ms
Error Handling.Does Not Retry On 403 ✅ 4718ms
Error Handling.Does Not Retry On 413 ✅ 4719ms
Error Handling.Retries On 408 ✅ 9235ms

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 ✅ 2793ms
Request Payload.Flags Request Uses V2 Query Param ✅ 2740ms
Request Payload.Flags Request Hits Flags Path Not Decide ✅ 2794ms
Request Payload.Flags Request Omits Authorization Header ✅ 2766ms
Request Payload.Token In Flags Body Matches Init ✅ 2764ms
Request Payload.Groups Round Trip ✅ 2670ms
Request Payload.Groups Default To Empty Object ✅ 2809ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False ✅ 2745ms
Request Payload.Disable Geoip Omitted Defaults To False ✅ 2810ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key ✅ 2774ms
Request Lifecycle.No Flags Request On Init Alone ✅ 50ms
Request Lifecycle.No Flags Request On Normal Capture ✅ 2699ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests ✅ 5478ms
Request Lifecycle.Mock Response Value Is Returned To Caller ✅ 2648ms
Retry Behavior.Retries Flags On 502 ✅ 2818ms
Retry Behavior.Retries Flags On 504 ✅ 2807ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event ✅ 3041ms

@github-actions

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in another week.

@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 3f7e21e: 5 Must fix, 4 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

This is the only automatic review while this pull request is a draft. Comment @posthog review to review new changes sooner. Otherwise, a review of the new changes runs when you mark it ready for review.

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.

@ioannisj
ioannisj force-pushed the feat/replay-gpu-mirror-capture branch from 90ea23d to 021470e Compare October 30, 2026 07:46
@ioannisj ioannisj changed the title feat: experimental GPU screenshot capture and screenshotScale for session replay feat: add experimental GPU screenshot capture for session replay Oct 30, 2026
@ioannisj
ioannisj added this pull request to stack #998 October 30, 2026 09:25
@github-actions github-actions Bot removed the stale label Oct 30, 2026
@ioannisj

Copy link
Copy Markdown
Contributor Author

@PostHog review

@posthog

posthog Bot commented Oct 30, 2026

Copy link
Copy Markdown
Contributor

@ioannisj PostHog Review is reviewing this pull request.

@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 021470e: 5 Must fix, 2 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

@github-actions

Copy link
Copy Markdown
Contributor

This PR hasn't seen activity in a week! Should it be merged, closed, or further worked on? If you want to keep it open, post a comment or remove the stale label – otherwise this will be closed in another week.

@github-actions github-actions Bot added the stale label Nov 10, 2026
@ioannisj ioannisj removed the stale label Nov 10, 2026
@turnipdabeets

turnipdabeets commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Review of ff3dce9d1: code, simulator and device. Each finding with a fix comes with a patch and tests at the bottom. Every new test fails on ff3dce9d1 and passes with the patch.

Update: the patch is pushed to this branch as cec484367.

For 4.0: this is 1/2 of a stack. #966 (inline masking) sits on top of it. #1008 is the default-path half of this PR split out, and today the two diverge (see Notes). main (v3.94.3) crashes the host app on device when a masked view sits under an app CALayer subclass without init(layer:). This PR doesn't, so that part matters for 4.0 even with the GPU flag off.

Before merge

  1. Land fix(replay): don't crash or leak masks on app CALayer subclasses in screenshot replay #1008, then rebase this onto main with fix(replay): don't crash or leak masks on app CALayer subclasses in screenshot replay #1008 in it. Keep fix: clear the replay screenshot bitmap and skip failed draws #999's skipped frame on a failed draw for both renderers, and re-check the two-turn split under feat(replay): schedule capture from a main run-loop observer instead of swizzling UIView layout #896's run-loop scheduler.
  2. CI green on the rebased head, including test-ios-simulator and the mask goldens on the pinned runtime. I couldn't run either locally.
  3. Fix the screenshotModeGPUCapture doc comment (see Notes).
  4. Re-measure turn 1 on a device. The numbers in the body predate the commit flush that now starts that turn.

The session-boundary gap is pre-existing, and I wouldn't block an opt-in flag on it. The flag is additive and opt-in, so this can ship in a 4.x minor rather than gate 4.0. What 4.0 needs is the default-path crash fix (#1008).

Findings with fixes

1. Default path leaks postHogMask() content during motion when the window holds an app layer class. This regresses from main. (bot thread 4442499196, still open)

  • In the motion band, a window holding a non-system CALayer subclass (Lottie, custom layers) is drawn from its model tree (settledRendering, PostHogMaskSettleAnalysis.swift:49-60). Each mask is widened only to its own owner's model rect.
  • Content inside .postHogMask() belongs to a different view than its reporter. When that content animates away, the model tree draws it outside the mask.
  • main masks this correctly. AppAnimatableLayer implements init(layer:), so main's presentation-tree render doesn't trap.
  • Fix: skip the frame in that case instead of drawing the model tree. That's fix(replay): don't crash or leak masks on app CALayer subclasses in screenshot replay #1008's approach, and the next settled capture covers it. remeasure/coveringModel go away with it.
  • Tests: 2 new failing cases (app layer moving, and safe content moving under a static app layer), plus controls for the presentation tree and the GPU mirror. The tests that asserted .modelTree now assert the skip.

2. A memory warning while a capture waits on warm-up starts a second warm-up. (bot thread 4442499212)

  • purge() calls the waiting completion. scheduleGPUCapture then re-enters and dispatches a new warm-up while the purged one is still running.
  • Result: two textures and two CARenderers at once, under memory pressure.
  • Fix: compare purgeGeneration in the completion and end the capture.
  • Test: purgeWhileCaptureWaitsOnWarmUpDoesNotWarmUpAgain.

3. plainCopy drops lineDashPhase and miterLimit on shape layers. (bot thread 4442499205)

  • Fix: two lines.
  • Test: shapeDashPhaseAndMiterLimitAreMirrored.

Notes (no patch)

  • Rebase. The branch is 26 commits behind main. Four files conflict: PostHogReplayIntegration.swift, UIView+Util.swift, PostHogReplayCaptureTouchesTest.swift and PostHogReplayScreenshotSizeTest.swift. Two conflicts need care:
  • fix(replay): don't crash or leak masks on app CALayer subclasses in screenshot replay #1008 vs this PR.
  • PR body vs code.
    • On device, a masked view under an animating app-class ancestor gets a whole-window mask on the default path. One all-black frame, then nothing while the animation runs, because identical frames are deduped. That fails closed, but the "Changes to the default path" section doesn't mention it. Only the "compose from each layer's own values" and model-tree rendering are described.
  • Doc comment on screenshotModeGPUCapture. It says app layers are left out "wherever that would draw masked content off its mask". The code leaves out every non-system layer subclass while it has a geometric animation (PostHogGPUMirrorCapture.swift:585-588), Lottie included. Suggestions:
    • Say that plainly.
    • Say which screenshotScale the 25–30 MB figure was measured at.
    • Note it also runs on Mac Catalyst.
  • Frames can cross a session boundary. (4195887474, deferred) timestampDate is taken in turn 1, but the session is resolved at enqueue, after readback. A reset() in between files the old screen under the new session. main's settled path has the same gap, but this one is longer.
  • UIWindow can be released on the replay queue. encodeMirrored's readback promotes [weak window] on dispatchQueue. If the window closes during readback, its last release is off main. This is a code trace only; I didn't reproduce it.
  • Tests.
    • The glass tests build private CABackdropLayer/CASDFElementLayer trees, so they test our model of glass, not UIKit's. They also fail rather than skip below iOS 26. Suggest an iOS 26 gate, plus one test using real UIGlassEffect.
    • purgeDuringPrewarmLeavesCacheEmpty and noWarmUpInBackground prove an absence with a 1 s sleep.
  • Lint. PostHogGPUMirrorCapture.swift is over file_length. The class body sits at 1199/1200 lines for type_body_length, which is an error, so any addition to PostHogReplayIntegration trips lint. The patch keeps it at 1199.
  • Pre-existing, not this PR. Image(systemName:).symbolEffect(.bounce) inside .postHogMask() leaks a 1–2 px sliver on both paths, because the bounce scales past the reporter's rect. I'll file it separately.

What I ran

  • Device: iPhone 14 Pro Max, iOS 26, Release, GPU on and off. Scenarios: static masks, a fast-scrolling list of masked fields, a moving masked label, an app CALayer subclass without init(layer:) (static, then animating), a sheet and an alert. Every sensitive element was magenta, and I scanned every uploaded frame for magenta.
    • No leaks and no crash on either path, apart from the pre-existing bounce sliver.
    • GPU capture: 0 hitches at 120 Hz in the static, scroll and animate phases. The default path had 7–18 hitches per phase, with 77–83 ms held frames. The performance claim holds on A16 too.
    • main crashes on the same screen (TrapLayer.init(layer:), from toPresentationRect → convertRect:toLayer:).
  • Simulator: Xcode 27.0, iOS 27.0. The full suite passes at ff3dce9d1 (1733 tests) and with the patch (1739). The GPU suite passes twice in a row.
    • Xcode 26.4.1 simulator runs never connected on my machine ("failed to bless service hub"), so I have no iOS 26 sim numbers.
    • The 18.6 runtime isn't installed.
  • Builds pass for iOS, macOS, tvOS, watchOS, visionOS, Mac Catalyst and swift build. swiftformat is clean.
Patch: source (3 files)
diff --git a/PostHog/Replay/PostHogGPUMirrorCapture.swift b/PostHog/Replay/PostHogGPUMirrorCapture.swift
index 2a9cb7463..f2be00385 100644
--- a/PostHog/Replay/PostHogGPUMirrorCapture.swift
+++ b/PostHog/Replay/PostHogGPUMirrorCapture.swift
@@ -406,7 +406,7 @@
         private var targets: [TargetKey: Target] = [:]
         private var warming: [TargetKey: [() -> Void]] = [:]
         /// Bumped by `purge`, so a warm-up it overtook doesn't refill the cache.
-        private var purgeGeneration = 0
+        private(set) var purgeGeneration = 0
         private var failedKeys: Set<TargetKey> = []
         private var classInfoCache: [ObjectIdentifier: ClassInfo] = [:]
         private var shareableTypeIDs: [CFTypeID: Bool] = [:]
@@ -928,6 +928,8 @@
                 copy.lineCap = shape.lineCap
                 copy.lineJoin = shape.lineJoin
                 copy.lineDashPattern = shape.lineDashPattern
+                copy.lineDashPhase = shape.lineDashPhase
+                copy.miterLimit = shape.miterLimit
                 copy.strokeStart = shape.strokeStart
                 copy.strokeEnd = shape.strokeEnd
                 return copy
diff --git a/PostHog/Replay/PostHogMaskSettleAnalysis.swift b/PostHog/Replay/PostHogMaskSettleAnalysis.swift
index 5a0a1effa..805e82488 100644
--- a/PostHog/Replay/PostHogMaskSettleAnalysis.swift
+++ b/PostHog/Replay/PostHogMaskSettleAnalysis.swift
@@ -28,35 +28,21 @@
             init(_ layer: CALayer, in window: UIWindow?) {
                 self.init(layer, rect: layer.toPresentationRect(window, outsets: layer.drawnOutsets(of: layer.bounds)))
             }
-
-            /// `rect` grown to also cover where the model tree places what it masks.
-            func coveringModel(in window: UIWindow) -> CGRect {
-                if let view = object as? UIView {
-                    return rect.union(view.convert(view.bounds.outset(by: view.drawnOutsets), to: window))
-                }
-                if let layer = object as? CALayer {
-                    return rect.union(layer.convert(layer.bounds.outset(by: layer.drawnOutsets(of: layer.bounds)), to: window.layer))
-                }
-                return rect
-            }
         }
 
         /// The renderer for a settled capture in `band`, and the masks that keep its pixels covered: in motion the
-        /// presentation tree, which `regions` were measured on, unless the window holds a layer whose presentation
-        /// can't be copied, which rendering it would trap on. The model tree is drawn then, and masked there too, from
-        /// `remeasure`: drawing it runs any pending layout first, which can move, add or remove what `regions` measured,
-        /// so that layout runs here, before the masks are measured again.
-        static func settledRendering(of window: UIWindow, band: SettleBand, inflatedRects: [CGRect]?, regions: [MaskedRegion]?,
-                                     remeasure: () -> [MaskedRegion]?) -> (renderer: PostHogScreenshotRenderer, rects: [CGRect]?)
+        /// presentation tree, which `regions` were measured on. nil when the window holds a layer whose presentation
+        /// can't be copied: rendering the presentation tree would trap on it, and the model tree draws content where
+        /// masks measured on the presentation don't cover it (`postHogMask()` content moving under its reporter), so the
+        /// frame is skipped and the next settled capture covers it.
+        static func settledRendering(of window: UIWindow, band: SettleBand, inflatedRects: [CGRect]?,
+                                     regions: [MaskedRegion]?) -> (renderer: PostHogScreenshotRenderer, rects: [CGRect]?)?
         {
             if band.usesFidelity {
                 return (.fidelity, inflatedRects ?? regions?.map(\.rect))
             }
-            if window.layer.canRenderPresentationTree {
-                return (.presentationTree, regions?.map(\.rect))
-            }
-            window.layoutIfNeeded()
-            return (.modelTree, remeasure()?.map { $0.coveringModel(in: window) })
+            guard window.layer.canRenderPresentationTree else { return nil }
+            return (.presentationTree, regions?.map(\.rect))
         }
 
         // Tuning surface for the settle check. Defaults are deliberately conservative: measured on
diff --git a/PostHog/Replay/PostHogReplayIntegration.swift b/PostHog/Replay/PostHogReplayIntegration.swift
index 42e5f1689..7fa9acce8 100644
--- a/PostHog/Replay/PostHogReplayIntegration.swift
+++ b/PostHog/Replay/PostHogReplayIntegration.swift
@@ -1696,8 +1696,8 @@
                 // Banded on mask geometry alone, not on layout-event counts: layout events arrive in
                 // bursts, so a count false-positives on the very burst that triggered the capture.
                 let verdict = Self.settleVerdict(before: sentinelRegions, after: regionsNow)
-                let rendering = Self.settledRendering(of: window, band: verdict.band, inflatedRects: verdict.inflatedRects,
-                                                      regions: regionsNow) { self.collectMaskedRegions(in: window) }
+                guard let rendering = Self.settledRendering(of: window, band: verdict.band, inflatedRects: verdict.inflatedRects,
+                                                            regions: regionsNow) else { return self.skipUncopyableMotionFrame() }
                 self.performScreenshotCapture(
                     window: window,
                     screenName: screenName,
@@ -2179,6 +2179,12 @@
             }
         }
 
+        /// Main thread. Ends a settled capture `settledRendering` gave no renderer for.
+        func skipUncopyableMotionFrame() {
+            hedgeLog("[Session Replay] Skipping snapshot: the window is in motion and holds a layer whose presentation can't be copied")
+            finishScreenshotRender()
+        }
+
         private func finishScreenshotRender() {
             screenshotRenderLock.withLock {
                 isScreenshotRenderInFlight = false
@@ -2315,9 +2321,11 @@
                     return
                 }
                 // The renderer is built off-main (see `prewarm`); this capture keeps the slot and resumes once it's ready.
+                let generation = mirror.purgeGeneration
                 mirror.prewarm(size: window.bounds.size, scale: scale) { [weak self, weak window] in
                     guard let self else { return }
-                    guard let window, postHog.isSessionReplayActive() else {
+                    // A memory warning purged the warm-up: don't start another one under memory pressure.
+                    guard let window, postHog.isSessionReplayActive(), mirror.purgeGeneration == generation else {
                         self.endGPUCapture(nil)
                         return
                     }
Patch: tests (PostHogGPUCaptureTest.swift)
diff --git a/PostHogTests/PostHogGPUCaptureTest.swift b/PostHogTests/PostHogGPUCaptureTest.swift
index 92e01fdc4..3c73c8d3a 100644
--- a/PostHogTests/PostHogGPUCaptureTest.swift
+++ b/PostHogTests/PostHogGPUCaptureTest.swift
@@ -323,32 +323,33 @@
 
         /// One capture's pixels and mask rects, through the GPU mirror or the default path's renderer for motion.
         private func capture(_ window: UIWindow, gpu: Bool, mirror: PostHogGPUMirrorCapture,
-                             integration: PostHogReplayIntegration) async throws -> (UIImage, [CGRect])
+                             integration: PostHogReplayIntegration) async throws -> (UIImage, [CGRect])?
         {
             if gpu {
                 let (image, rects) = try await render(window, with: mirror, integration: integration)
                 return (UIImage(cgImage: image), rects)
             }
             window.layoutIfNeeded()
-            let motion = try motionCapture(window, integration: integration)
+            // nil: the default path skips the frame, which uploads nothing.
+            guard let motion = try motionCapture(window, integration: integration) else { return nil }
             return (motion.image, motion.rects)
         }
 
-        /// The default path's pixels and mask rects for a settled capture in the motion band.
+        /// The default path's pixels and mask rects for a settled capture in the motion band, nil when it skips the frame.
         private func motionCapture(_ window: UIWindow, integration: PostHogReplayIntegration = PostHogReplayIntegration()) throws
-            -> (image: UIImage, rects: [CGRect], renderer: PostHogScreenshotRenderer)
+            -> (image: UIImage, rects: [CGRect], renderer: PostHogScreenshotRenderer)?
         {
             try settledCapture(window, band: .motion, integration: integration)
         }
 
-        /// The default path's pixels and mask rects for a settled capture in `band`.
+        /// The default path's pixels and mask rects for a settled capture in `band`, nil when it skips the frame.
         private func settledCapture(_ window: UIWindow, band: PostHogReplayIntegration.SettleBand,
                                     integration: PostHogReplayIntegration = PostHogReplayIntegration()) throws
-            -> (image: UIImage, rects: [CGRect], renderer: PostHogScreenshotRenderer)
+            -> (image: UIImage, rects: [CGRect], renderer: PostHogScreenshotRenderer)?
         {
             let regions = try #require(integration.collectMaskedRegions(in: window))
-            let rendering = PostHogReplayIntegration.settledRendering(of: window, band: band, inflatedRects: nil, regions: regions) {
-                integration.collectMaskedRegions(in: window)
+            guard let rendering = PostHogReplayIntegration.settledRendering(of: window, band: band, inflatedRects: nil, regions: regions) else {
+                return nil
             }
             return try (#require(window.toImage(renderer: rendering.renderer)), #require(rendering.rects), rendering.renderer)
         }
@@ -418,9 +419,9 @@
 
         // MARK: - Default capture renderer for motion
 
-        @Test("A window holding a layer whose presentation can't be copied is drawn for motion from its model, without copying that layer",
+        @Test("A window holding a layer whose presentation can't be copied skips the motion frame, without copying that layer",
               arguments: [false, true])
-        func motionRendererDrawsUncopyableTreeFromItsModel(animating: Bool) async throws {
+        func motionFrameIsSkippedForUncopyableTree(animating: Bool) async throws {
             let window = makeWindow()
             try #require(!PostHogGPUMirrorCapture.isPresentationSafe(TrappingLayer.self))
             let view = TrappingView(frame: CGRect(x: 20, y: 20, width: 100, height: 100))
@@ -433,14 +434,13 @@
                 try await hold(view.layer, keyPath: "opacity", at: Float(1))
             }
 
-            let (image, _, renderer) = try motionCapture(window)
-            #expect(renderer == .modelTree)
-            let side = Int(100 * image.scale)
-            #expect(try Pixels(#require(image.cgImage)).points(where: Self.isMagenta).count == side * side)
+            #expect(try motionCapture(window) == nil)
+            // Still settled: the frame is drawn, with drawHierarchy.
+            #expect(try settledCapture(window, band: .still)?.renderer == .fidelity)
         }
 
-        @Test("Masks for motion also cover where the model places a view presented elsewhere, when the window is drawn from its model")
-        func motionMasksCoverModelOfModelDrawnWindow() async throws {
+        @Test("A motion frame isn't drawn when a view is presented away from its model in a window holding a layer whose presentation can't be copied")
+        func motionFrameIsSkippedWhenPresentedAwayFromModel() async throws {
             let window = makeWindow()
             window.addSubview(TrappingView(frame: CGRect(x: 150, y: 250, width: 40, height: 40)))
             let field = secretField(CGRect(x: 20, y: 20, width: 120, height: 60))
@@ -450,9 +450,7 @@
             try await Task.sleep(nanoseconds: 50_000_000)
             try await hold(field.layer, keyPath: "position", at: NSValue(cgPoint: CGPoint(x: 80, y: 200)))
 
-            let (image, rects, renderer) = try motionCapture(window)
-            #expect(renderer == .modelTree)
-            try expectMagentaMasked(image, rects)
+            #expect(try motionCapture(window) == nil)
         }
 
         private struct MaskedMagentaContent: View {
@@ -467,8 +465,8 @@
             }
         }
 
-        @Test("A postHogMask view presented collapsed but laid out full size is masked where the model draws it, when the window is drawn from its model")
-        func motionMasksCoverModelOfCollapsedReporter() async throws {
+        @Test("A postHogMask view presented collapsed but laid out full size keeps its mask, and its motion frame isn't drawn from the model")
+        func collapsedReporterKeepsItsMask() async throws {
             let window = makeSceneWindow()
             defer { closeSceneWindow(window) }
             let host = UIHostingController(rootView: MaskedMagentaContent())
@@ -484,10 +482,8 @@
             // Kept in both samples, the collapsed reporter pairs with itself rather than reading as motion.
             let samples = (integration.collectMaskedRegions(in: window), integration.collectMaskedRegions(in: window))
             #expect(PostHogReplayIntegration.settleVerdict(before: samples.0, after: samples.1).band == .still)
-            let (image, rects, renderer) = try motionCapture(window, integration: integration)
-            #expect(renderer == .modelTree)
-            try #require(!rects.isEmpty, "the collapsed reporter's mask was dropped")
-            try expectMagentaMasked(image, rects)
+            #expect(try !#require(samples.1).isEmpty, "the collapsed reporter's mask was dropped")
+            #expect(try motionCapture(window, integration: integration) == nil)
         }
 
         /// Runs `pendingLayout` the next time it lays out.
@@ -526,18 +522,15 @@
             return (container, field)
         }
 
-        @Test("A layout pending when masks are measured, which drawing the model tree runs, leaves no magenta unmasked where it moves or adds a view",
-              arguments: [false, true])
-        func modelTreeMasksCoverWhatAPendingLayoutMovesOrAdds(adds: Bool) async throws {
+        @Test("A skipped motion frame runs no pending layout", arguments: [false, true])
+        func skippedMotionFrameLeavesAPendingLayoutPending(adds: Bool) async throws {
             let window = makeWindow()
             window.addSubview(TrappingView(frame: CGRect(x: 150, y: 250, width: 40, height: 40)))
             let (container, field) = try await fieldWithPendingRelayout(in: window, adds: adds)
 
-            let (image, rects, renderer) = try motionCapture(window)
-            #expect(renderer == .modelTree)
-            #expect(container.pendingLayout == nil)
-            #expect(field.frame.minY == (adds ? 20 : 160))
-            try expectMagentaMasked(image, rects)
+            #expect(try motionCapture(window) == nil)
+            #expect(container.pendingLayout != nil)
+            #expect(field.frame.minY == 20)
         }
 
         @Test("A layout pending when masks are measured stays pending through the presentation-tree and fidelity renders, which draw what was measured",
@@ -547,7 +540,7 @@
             let (container, field) = try await fieldWithPendingRelayout(in: window)
             let layouts = container.layoutCount
 
-            let (image, rects, renderer) = try settledCapture(window, band: fidelity ? .still : .motion)
+            let (image, rects, renderer) = try #require(try settledCapture(window, band: fidelity ? .still : .motion))
             #expect(renderer == (fidelity ? .fidelity : .presentationTree))
             #expect(container.layoutCount == layouts)
             #expect(field.frame.minY == 20)
@@ -593,7 +586,7 @@
             try #require(field.layer.presentation() == nil)
             let expected = try #require(parent.layer.presentation()).convert(field.frame, to: window.layer)
 
-            let (image, rects) = try await capture(window, gpu: gpu, mirror: mirror, integration: PostHogReplayIntegration())
+            let (image, rects) = try #require(try await capture(window, gpu: gpu, mirror: mirror, integration: PostHogReplayIntegration()))
             #expect(rects.contains { $0.insetBy(dx: -1, dy: -1).contains(expected) })
             if gpu {
                 try expectMagentaMasked(image, rects)
@@ -629,7 +622,7 @@
                 // `build` commits first, which redraws the label without the text.
                 try await expectNoUnmaskedMagenta(window, mirror: mirror, integration: integration, expect: .hidden)
             } else {
-                let (image, rects) = try await capture(window, gpu: false, mirror: mirror, integration: integration)
+                let (image, rects) = try #require(try await capture(window, gpu: false, mirror: mirror, integration: integration))
                 #expect(rects.contains(label.frame))
                 try expectMagentaMasked(image, rects)
             }
@@ -682,7 +675,7 @@
             if gpu {
                 try await expectNoUnmaskedMagenta(window, mirror: mirror, integration: integration, expect: .hidden)
             } else {
-                let (image, rects) = try await capture(window, gpu: false, mirror: mirror, integration: integration)
+                let (image, rects) = try #require(try await capture(window, gpu: false, mirror: mirror, integration: integration))
                 try expectMagentaMasked(image, rects)
             }
         }
@@ -956,12 +949,13 @@
             let regions = integration.collectMaskedRegions(in: window)
             let verdict = PostHogReplayIntegration.settleVerdict(before: sentinel, after: regions)
             let rendering = PostHogReplayIntegration.settledRendering(of: window, band: verdict.band, inflatedRects: verdict.inflatedRects,
-                                                                      regions: regions) { integration.collectMaskedRegions(in: window) }
-            let rects = try #require(rendering.rects)
+                                                                      regions: regions)
             if distance > PostHogReplayIntegration.driftBudgetPoints {
-                #expect(rendering.renderer == .modelTree)
-                try expectMagentaMasked(try #require(window.toImage(renderer: rendering.renderer)), rects)
+                // Motion in a window holding the app layer: the frame is skipped.
+                #expect(rendering == nil)
             } else {
+                let rendering = try #require(rendering)
+                let rects = try #require(rendering.rects)
                 // drawHierarchy, which shows the last commit.
                 #expect(rendering.renderer == .fidelity)
                 #expect(rects.contains { $0.insetBy(dx: -1, dy: -1).contains(committed) }, "\(rects)")
@@ -1216,6 +1210,56 @@
             return content
         }
 
+        @Test("A motion frame isn't drawn while an app layer in postHogMask content animates away from its reporter")
+        func motionFrameSkippedForAppLayerMovingUnderReporter() async throws {
+            let mirror = try makeMirror()
+            let window = makeSceneWindow()
+            defer { closeSceneWindow(window) }
+            await prewarm(mirror, for: window)
+            let content = try await hostMaskedAppLayerContent(in: window, mirror: mirror)
+            try await animateDown(content.layer, by: 150)
+            // Skipped: the model tree would draw the content away from its reporter's mask.
+            #expect(try motionCapture(window) == nil)
+        }
+
+        @Test("A motion frame isn't drawn while postHogMask content under a static app layer animates away from its reporter")
+        func motionFrameSkippedForContentMovingUnderStaticAppLayer() async throws {
+            let mirror = try makeMirror()
+            let window = makeSceneWindow()
+            defer { closeSceneWindow(window) }
+            await prewarm(mirror, for: window)
+            let content = try await hostMaskedAppLayerContent(in: window, mirror: mirror)
+            let secret = try #require(content.subviews.first)
+            try await animateDown(secret.layer, by: 150)
+            #expect(try motionCapture(window) == nil)
+        }
+
+        @Test("postHogMask content with a copyable app layer animating away is masked on the presentation tree")
+        func copyableAppLayerMovingUnderReporterIsMaskedOnPresentationTree() async throws {
+            let mirror = try makeMirror()
+            let window = makeSceneWindow()
+            defer { closeSceneWindow(window) }
+            await prewarm(mirror, for: window)
+            let content = try await hostMaskedAppLayerContent(in: window, mirror: mirror)
+            try await animateDown(content.layer, by: 150)
+            // AppAnimatableLayer implements init(layer:), so main's presentation-tree render doesn't trap on it.
+            let regions = try #require(PostHogReplayIntegration().collectMaskedRegions(in: window))
+            let image = try #require(window.toImage(renderer: .presentationTree))
+            try expectMagentaMasked(image, regions.map(\.rect))
+        }
+
+        @Test("postHogMask content animating away under a static app layer is masked in the GPU mirror")
+        func contentMovingUnderStaticAppLayerIsMaskedInMirror() async throws {
+            let mirror = try makeMirror()
+            let window = makeSceneWindow()
+            defer { closeSceneWindow(window) }
+            await prewarm(mirror, for: window)
+            let content = try await hostMaskedAppLayerContent(in: window, mirror: mirror)
+            let secret = try #require(content.subviews.first)
+            try await animateDown(secret.layer, by: 150)
+            try await expectNoUnmaskedMagenta(window, mirror: mirror)
+        }
+
         @Test("A static app layer in postHogMask content is drawn under the reporter's mask")
         func staticAppLayerInPostHogMaskContentIsDrawn() async throws {
             let mirror = try makeMirror()
@@ -2875,6 +2919,51 @@
             #expect(mirror.needsPrewarm(for: window.bounds.size, scale: 1))
         }
 
+        @Test("A memory warning while a capture waits on the warm-up ends that capture, without starting another warm-up")
+        func purgeWhileCaptureWaitsOnWarmUpDoesNotWarmUpAgain() async throws {
+            let mirror = try makeMirror()
+            let window = makeWindow()
+            PostHogReplayIntegration.prewarmWindowForTesting = window
+            defer { PostHogReplayIntegration.prewarmWindowForTesting = nil }
+            let (sut, integration, _) = try makeSut()
+            integration.gpuMirror = mirror
+            defer { sut.close() }
+            let scale = integration.gpuPixelScale(window)
+            // Let any warm-up the SDK's setup started finish, then start from an empty cache.
+            try await Task.sleep(nanoseconds: 1_000_000_000)
+            mirror.purge()
+            try #require(mirror.needsPrewarm(for: window.bounds.size, scale: scale))
+
+            #expect(integration.startScreenshotCapture(window: window, screenName: nil, postHog: sut))
+            mirror.purge()
+            await waitForCaptureToFinish(integration)
+            // Long enough for a second warm-up, had one started, to finish.
+            try await Task.sleep(nanoseconds: 1_000_000_000)
+            #expect(mirror.needsPrewarm(for: window.bounds.size, scale: scale))
+        }
+
+        @Test("A shape layer's dash phase and miter limit are mirrored")
+        func shapeDashPhaseAndMiterLimitAreMirrored() async throws {
+            let mirror = try makeMirror()
+            let window = makeWindow()
+            await prewarm(mirror, for: window)
+            let shape = CAShapeLayer()
+            shape.frame = CGRect(x: 20, y: 20, width: 100, height: 100)
+            shape.path = CGPath(rect: CGRect(x: 0, y: 0, width: 100, height: 100), transform: nil)
+            shape.strokeColor = UIColor.black.cgColor
+            shape.lineDashPattern = [6, 4]
+            shape.lineDashPhase = 3
+            shape.miterLimit = 2
+            window.layer.addSublayer(shape)
+            CATransaction.flush()
+
+            let frame = try #require(mirror.build(window: window, scale: 1))
+            defer { frame.release() }
+            let copy = try #require(mirror.copiesForTesting.allObjects.compactMap { $0 as? CAShapeLayer }.first { $0.lineDashPattern == [6, 4] })
+            #expect(copy.lineDashPhase == 3)
+            #expect(copy.miterLimit == 2)
+        }
+
         @Test("No GPU warm-up runs while the app is in the background, and one runs once it becomes active")
         func noWarmUpInBackground() async throws {
             let mirror = try makeMirror()

turnipdabeets and others added 2 commits November 10, 2026 13:01
…hape copies

- Default path: in the motion band, a window holding a layer whose presentation can't be copied now skips the frame instead of drawing the model tree, which drew postHogMask() content away from its reporter's mask (as #1008 does).
- A memory warning while a capture waits on warm-up ends that capture instead of starting a second warm-up.
- Shape copies keep lineDashPhase and miterLimit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019xLynxqQ97w5iqqo4Dxyi5
Conflict resolutions:
- UIView+Util toImage: keep the renderer switch and record drawHierarchy's result in both drawHierarchy branches, so #999's failed-draw skip applies to the fidelity and bridge renders.
- Replay integration: becoming active wakes the capture backoff (#896), then resumes plugins and prewarms GPU capture; collectScreenshotMetadata keeps the renderer API and skips a tick whose attempted render failed (#999).
- Tests: touch tests use the shared ReplayTestSupport helpers with main's new cases, PresentingController, makeWindow and the maskAllTextInputs parameter; the screenshot size test draws only the presentation-tree renderer, since failed drawHierarchy renders are now dropped in the hostless bundle.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019xLynxqQ97w5iqqo4Dxyi5
turnipdabeets added a commit that referenced this pull request Nov 10, 2026
…line-masking

Brings in #911's current head, which includes main.

Conflict resolutions:
- Portals: take #911's model (the portal and, for one shown elsewhere, its rect; the whole portal is masked unless the source is clean). The whole-portal and unsettled-source regions are tagged .rectOnly. wholePortals and hasInPlaceCopy are removed: the walk already masks the whole portal whenever a mask sits under or over its source.
- GPU capture: mirrorAndMask gains a .fallback case for #911's culled full-window cover, checked before inline masking; maskableRects/GPUMaskWalk stay for the plain walk, sharing the cover check.
- postHogMask reporters keep #911's region(of:) (collapsed reporters with a model size) with an explicit inline tag: .extent on the main walk, .rectOnly through a portal.
- MaskedRegion's view and layer initializers keep #1000's drawn outsets with an explicit .owned tag.
- contentsKeys stays internal for inline masking and gains #911's contentsTransformKey.
- GPU capture tests take #911's FrameOutcome-based expectNoUnmaskedMagenta, with noCaptureView from GPUCaptureTestSupport.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_019xLynxqQ97w5iqqo4Dxyi5

@ioannisj ioannisj left a comment

Copy link
Copy Markdown
Contributor Author

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.

Ran it on iOS 27.2 (iPhone 18 Pro simulator, Xcode 27), the images and numbers are in the description now. Median difference from the screen is 0.15 for drawHierarchy and 0.77 for GPU capture, vs 0.08 and 0.59 on 26.5.

It did find two 27-only regressions, both fixed in 325d935: glass bar buttons lost their text and icons (27 draws each one through a portal whose bounds start at the button's position, and the mirror pinned the source to that origin), and clear glass picked up the tinted fill (its margin and output range aren't exactly 0 and 1 any more).

What's still off on 27 is sheets, alerts and dialogs: GPU capture draws them as a flat panel instead of the blurred material, up to a 38 mean on the dark SwiftUI sheet where 26 sits at 6. I'm leaning towards leaving that as a known gap, since the flag is experimental and off by default, and sorting the panel material out in a follow-up. Is there a reason to hold this PR on it?

@ioannisj ioannisj left a comment •

Copy link
Copy Markdown
Contributor Author

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.

The last open bot thread is answered below. The model-tree fallback it flagged is gone, since the default path's handling of uncopyable layers stays with #1008.

@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 a6c20aa: 2 Must fix, 1 Should fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

This is review round 9 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

turnipdabeets added a commit that referenced this pull request Nov 12, 2026
* feat: keep screenshotModeBackgroundCapture in 4.0

Reverts #937. GPU capture (#911) stays experimental and off by default,
so the main-thread drawHierarchy path remains the default in 4.0 and
background capture is the only off-main option. It unblocks the
main-thread hangs reported in #1035.

Drops the removed-API stub, the migration-guide row and the changeset
mention, and restores the public API snapshot line.

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

* fix(replay): skip hidden or transitioning windows before background capture

Background capture walked the hierarchy twice and rendered the window
before prepareScreenshotWireframe discarded the frame during a view
controller transition. Check visibility and transition state first, as
scheduleSettledCapture already does.

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

* chore: order the restored screenshotModeBackgroundCapture snapshot line

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

* fix(replay): release background-capture mask samples on the main thread

MaskedRegion retains its view, so the before sample could hold the last
reference to a view and release it on the replay queue, running UIKit
teardown off main. Drop it inside the second main.sync instead.

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

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
@ioannisj
ioannisj marked this pull request as ready for review November 12, 2026 19:51

@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 5af7451: 1 Must fix. This pull request is large, so the review ran in 4 parts with less depth than usual.

This is review round 10 on this pull request. When findings keep coming back in the same code, the approach is usually the problem. Before the next fix, check whether a different design would remove the whole class of findings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
# Conflicts:
#	PostHog/Replay/PostHogReplayIntegration.swift
@turnipdabeets
turnipdabeets enabled stacked auto-merge (squash) November 13, 2026 19:24
@turnipdabeets
turnipdabeets merged commit 13bd1ae into main Nov 13, 2026
53 checks passed
@turnipdabeets
turnipdabeets deleted the feat/replay-gpu-mirror-capture branch November 13, 2026 19:52
turnipdabeets added a commit that referenced this pull request Nov 13, 2026
…cated

Brings in 3.95.0-3.96.2 and GPU screenshot capture (#911, #966).
Resolved for 4.0: screenshot capture always on (screenshotMode stays
removed, GPU capture docs no longer reference it), RRWireframe ->
RRScreenshotFrame, PostHog.podspec stays deleted, new GPU/replay
sources and tests gated on the SessionReplay trait, survey cancel
events gated on Surveys.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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