Repository navigation
feat: add experimental GPU screenshot capture for session replay - #911
Conversation
|
[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" |
posthog-ios Compliance ReportDate: 2026-11-13T19:34:23.288951+00:00
|
| 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 |
|
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 |
There was a problem hiding this comment.
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.
… and free renderers in GPU screenshot capture
90ea23d to
021470e
Compare
|
@PostHog review |
|
@ioannisj PostHog Review is reviewing this pull request. |
… match their masks
…de what overflows them
…offset windows line up with their masks
…back their waiters
…on becoming active
|
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 |
|
Review of Update: the patch is pushed to this branch as 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). Before merge
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 fixes1. Default path leaks
2. A memory warning while a capture waits on warm-up starts a second warm-up. (bot thread 4442499212)
3.
Notes (no patch)
What I ran
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 (
|
…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
…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
…GPU capture tests
…r-capture # Conflicts: # PostHog/Replay/UIView+Util.swift # PostHogTests/PostHogScreenshotFailedDrawTest.swift
ioannisj
left a comment
There was a problem hiding this comment.
🤖 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?
There was a problem hiding this comment.
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.
…and require an Apple GPU in GPU capture
* 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>
There was a problem hiding this comment.
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
…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>
💡 Motivation and Context
Screenshot-mode session replay captures with
drawHierarchyon 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:
CALayersubclass is never copied by the mirror, and every failure falls back to the existing pathmain(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
CARendererrenders 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 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
CALayersubclasses get one rule in the mirror. Their presentation can't be copied safely (a subclass withoutinit(layer:)traps), so they're drawn from their committed model state: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:
NaN) corner radii, and image contents transform, tint and swizzle, are copied from their private keys_SwiftUIProxyImagecontents) are shared with the rendererCARenderer, so it's drawn as a stand-in:UIVisualEffectViewmaterial (.systemThinMaterialLight/.systemThinMaterialDark), so text behind them stays blurred like on screen. NoCAFilterorCABackdropLayeris createdWhat it doesn't capture:
MTKView) shows as a striped "Metal content" placeholder, capped at 1024 pxIf 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
screenshotScaleunset, one pixel per point, and a larger scale holds more. It's documented on the flag.screenshotScaleGPU capture uses the
screenshotScaleoption from #994 through the same resolver as the default path, so both paths upload the same size for the same config:0.5is 1.5 pixels per point on a 3x device and1.0is nativeGPU 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
maindoes, so a masked view under a layer whose copy traps would trap there, as it does onmaintoday.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?
PostHogGPUCaptureTestcovers 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 aCALayersubclass 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.5and1.0screenshotScale, 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.
PostHogMaskFailClosedTestis back tomain'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,screenshotScaleunset: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
drawHierarchyand 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.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
portalContentpinned 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.
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:
PostHogGraphicsImageRendererallocates its bitmap withmallocand 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
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
DRI: @ioannisj
Autonomy: Human-driven (agent-assisted)