Skip to content

fix(ios): honour zPosition when capturing with useRenderInContext (#677) - #696

Open
gre wants to merge 3 commits into
masterfrom
fix/677-render-in-context-repro
Open

fix(ios): honour zPosition when capturing with useRenderInContext (#677)#696
gre wants to merge 3 commits into
masterfrom
fix/677-render-in-context-repro

Conversation

@gre

@gre gre commented Sep 5, 2026

Copy link
Copy Markdown
Owner

TL;DR — help wanted on one specific point

This fixes a real, measured iOS capture bug and adds the first test coverage useRenderInContext has ever had. But it does not reproduce #677 as filed, and I would like a second opinion on whether it actually solves the reporter's problem.

The bug

-[CALayer renderInContext:] paints sublayers in sublayers array order and ignores zPosition, which the live compositor honours.

Fabric depends on that difference. In RCTViewComponentView.mm, useCoreAnimationBorderRendering is false when a border is present, opaque, and the view does not clip:

borderMetrics.borderWidths.left == 0 || self.currentContainerView.clipsToBounds ||
(colorComponentsFromColor(borderMetrics.borderColors.left).alpha == 0 && ...)

In that case RN stops using layer.backgroundColor and appends sublayers, keeping them behind the content solely via zPosition:

layer.backgroundColor = nil;
_backgroundColorLayer = [CALayer layer];
_backgroundColorLayer.zPosition = BACKGROUND_COLOR_ZPOSITION;  // -1024.0f
[layer addSublayer:_backgroundColorLayer];

Appended last, it is painted last through renderInContext: — over the content. The view is captured as a flat white block.

The fix

Sort each sublayers array by zPosition before rendering, restore afterwards. The sort is stable, so layers sharing a zPosition keep array order — Core Animation's own rule. Only arrays whose order actually differs are touched; restore happens in a @finally inside a CATransaction with actions disabled, so no reordered frame is ever presented.

The change is confined to the if (renderInContext) branch. On the old architecture it is a no-op: RCTView.m's reactZIndexSortedSubviews already keeps array order aligned with zPosition.

Measured results

iPhone 17 Pro, RN 0.84.1, pixel-level comparison of the actual captured PNGs:

card before after
plain — no border
borderborderWidth: 1 uniqueColors = 1, flat block
border-clipped+ overflow: hidden
nested-border — border on a child of the capture root ❌ flat block
scroll-borderthe reporter's exact setup

Verified by actually reverting the native change and rebuilding, not by reasoning.

⚠️ Where I need help

The configuration #677 reports does not reproduce. A vertical ScrollView captured with snapshotContentContainer: true, whose items carry borderWidth: 1 — exactly what the reporter describes — renders identically in both modes, with and without this change.

So this PR fixes a bug with #677's signature without being provably the same instance of it.

What I could not explain: why nested-border breaks (border on a descendant of the capture root) while scroll-border does not. One unverified lead — snapshotContentContainer resizes scrollView.frame, forcing a layout pass, which might remount content layers after the background layer and fix the order by accident. If that is right, the bug is mount-order dependent and therefore intermittent, which would explain why only some of the reporter's items lose their content.

Questions for reviewers:

  1. Does the mount-order hypothesis hold? Anyone able to reproduce [iOS][Fabric] Content disappears when capturing a ScrollView item with borderWidth and snapshotContentContainer #677 with snapshotContentContainer?
  2. Is temporarily reordering a live layer tree acceptable here, or should this go the harder route of a manual zPosition-aware render?
  3. RN version: measured on 0.84.1, reporter is on 0.82.1.

I would not close #677 on this PR alone. The reporter's minimal repro — which I had declared unnecessary — is worth asking for after all.

Also in here

useRenderInContext was documented and plumbed but exercised nowhere — no example screen, no E2E test. That is why this went unnoticed. This adds both.

example/e2e/helpers/pixels.js decodes the PNG the library actually wrote. The existing snapshot-matcher.js compares byte sizes of device.takeScreenshot() output, which answers a different question and cannot see this class of bug.

Assertions compare the two capture strategies against each other rather than against reference snapshots, so they hold across iOS versions, device scales and font rendering.

Not addressed

The "shadows make capture slow" part of #677 is a separate, upstream issue: snapshotContentContainer resizing the ScrollView frame forces a synchronous layout of every item, each regenerating its box-shadow layer image — O(N) in RN, not in this library.

🤖 Generated with Claude Code

https://claude.ai/code/session_01KPH1Rc1m1tVDWTytveAa4T

gre added 3 commits September 5, 2026 13:25
…ntent

Adds a failing E2E reproduction for #677, ahead of any fix.

`useRenderInContext` was plumbed but never exercised: before this commit the
option appeared only in the README, the TurboModule spec types and the
Objective-C++ that reads it — no example screen, no E2E test. That is why the
regression went unnoticed.

Mechanism: `-[CALayer renderInContext:]` walks `sublayers` in array order and
ignores `zPosition`. Under Fabric, `RCTViewComponentView` stops using
`layer.backgroundColor` and appends a `_backgroundColorLayer` whenever it
cannot express the border through plain CoreAnimation properties (see
`useCoreAnimationBorderRendering`), keeping it behind the content solely via
`zPosition = -1024`. Appended last, it is painted last through
`renderInContext:` — over the content — so the view is captured as a flat
block. `drawViewHierarchyInRect` goes through the render server and is
unaffected.

The new screen renders three cards with identical content, differing only in
the style that selects RN's rendering path: no border, `borderWidth: 1`, and
`borderWidth: 1` with `overflow: hidden`. The third one separates the appended
sublayer branch from the `createMaskLayer` branch, which needs a different fix.

Assertions compare the two capture strategies against each other rather than
against reference snapshots, so they hold across iOS versions, device scales
and font rendering. `example/e2e/helpers/pixels.js` decodes the PNG the library
actually wrote — the existing snapshot matcher compares byte sizes of device
screenshots, which cannot see this class of bug.

The bordered-card tests are expected to fail until the fix lands. The Detox iOS
CI step is `continue-on-error`, so this does not turn the build red.

Refs #677
`-[CALayer renderInContext:]` paints sublayers in `sublayers` array order and
ignores `zPosition`, which the live compositor honours. Fabric depends on that
difference: `RCTViewComponentView` appends a `_backgroundColorLayer` (and a
`_borderLayer`) whenever a view's border cannot be expressed through plain
CoreAnimation properties, and keeps them behind the content only by giving them
`zPosition = -1024`. Appended last, they are painted last through
`renderInContext:` — over the content — so the view is captured as a flat block.

Sorting each `sublayers` array by zPosition before rendering restores what the
compositor would have drawn. The sort is stable, so layers sharing a zPosition
keep their array order, which is Core Animation's own rule. Only arrays whose
order actually differs are touched, and the tree is restored in a `@finally`
inside a `CATransaction` with actions disabled, so nothing is ever presented.

Scope: the change is confined to the `if (renderInContext)` branch, which no
other example screen or E2E test exercises.

Measured on iPhone 17 Pro / RN 0.84.1 — before the fix, `border` and
`nested-border` captured as flat blocks (uniqueColors = 1) while `plain`,
`border-clipped` and `scroll-border` were already correct; after it, all five
match `drawViewHierarchyInRect`.

Two findings the reproduction settled, both recorded in PLAN-677.md:

- `overflow: hidden` is unaffected, because `clipsToBounds` flips
  `useCoreAnimationBorderRendering` back to true and no extra layers are
  appended. The mask branch is not involved, so reordering is enough and no
  manual re-implementation of compositing is needed.
- The configuration #677 actually reports — a ScrollView captured with
  `snapshotContentContainer` whose items carry the border — does NOT reproduce
  here, with or without this change. What is fixed shares #677's signature
  without being provably the same instance, so the issue should not be closed
  on this commit alone.

Refs #677
- Skip the new E2E suite on Android. `useRenderInContext` is iOS-only and
  `result: 'tmpfile'` returns an emulator-side path the host test process
  cannot read, so `beforeAll` would throw and take all five tests down on
  `npm run test:e2e:android`. jest testMatch picks the file up for both
  configurations, so the guard has to be explicit.
- Reorder the current sublayers on restore instead of assigning the pre-render
  snapshot back. `renderInContext:` drives `display`/`drawInContext:`, so a
  delegate adding or removing a sublayer mid-render would otherwise have its
  addition dropped or its removal undone, corrupting the live view.
- Clamp the region origin from above in `clampRegion`. An out-of-bounds x/y
  read past the buffer, which turned the statistics into NaN and collapsed
  `uniqueColors` to 1 — indistinguishable from "flat block", i.e. a false bug
  signal rather than an error.
- Declare `expect` as a devDependency. It only resolved by hoisting out of
  jest's tree, which breaks silently under a stricter installer.
- Flip the HomeScreen entry to `status: 'tested'`. It shipped as `'bug'` next
  to StyleFilters (#578), which is genuinely still broken; leaving both red
  made them indistinguishable.
- Stop tracking PLAN-677.md. It is a session working document, not a repo
  deliverable; its useful residue lives in the code comments, the test screen
  and the PR description.

All five cards still match `drawViewHierarchyInRect` after the restore change.

Refs #677
Copilot AI lite review requested due to automatic review settings September 5, 2026 11:35

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🟡 Changes recommended

The new example/test additions include a dead file reference and a few misleading/inaccurate comments that should be corrected before merging.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Fixes an iOS/Fabric rendering discrepancy in useRenderInContext captures by temporarily reordering CALayer.sublayers to respect zPosition during renderInContext:, and adds an example screen + Detox E2E pixel assertions to exercise this path in the example app.

Changes:

  • iOS: sort each layer’s sublayers by zPosition before renderInContext: and restore ordering afterward.
  • Example app: add a dedicated RenderInContext screen demonstrating the bordered-view failure mode and exposing capture URIs for tests.
  • Detox E2E: add pixel-level PNG decoding/diff utilities and a new test that compares drawViewHierarchyInRect vs renderInContext: outputs.
File summaries
File Description
ios/RNViewShot.mm Sort/restore sublayer order around renderInContext: to honor zPosition.
example/src/screens/RenderInContextTestScreen.tsx New repro/diagnostic screen for useRenderInContext behavior (and test hooks).
example/src/screens/HomeScreen.tsx Adds navigation entry for the new RenderInContext test screen.
example/App.tsx Registers the new RenderInContext screen in the navigation stack.
example/e2e/tests/render-in-context.test.js New Detox test that captures cards in both modes and asserts pixel equivalence.
example/e2e/helpers/pixels.js PNG decode + region statistics + pixel diff helpers (pixelmatch/pngjs).
example/package.json Adds expect, pixelmatch, and pngjs devDependencies for the new E2E assertions.
example/package-lock.json Locks added dependencies.
Review details

Files not reviewed (1)

  • example/package-lock.json: Generated file

Suppressed comments (2)

example/src/screens/RenderInContextTestScreen.tsx:186

  • The intro text says “the three cards should look identical”, but this screen renders 5 cards (plain, border, border-clipped, nested-border, scroll-border). This makes the instructions misleading for manual testing.
            Capture both ways: the three cards should look identical in both
            columns. Any card that comes back as a blank block is the bug.

example/e2e/tests/render-in-context.test.js:73

  • The setup comment says each button captures “all three cards”, but CARDS contains 5 entries. The second line also refers to “the three cards”.
    // Capture through both strategies. Each button captures all three cards.
    // The buttons sit below the three cards, so they start off-screen.
  • Files reviewed: 7/8 changed files
  • Comments generated: 3
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread ios/RNViewShot.mm
Comment on lines +84 to +90
NSMutableArray<CALayer *> *restored = [NSMutableArray arrayWithCapacity:current.count];
for (CALayer *sublayer in original) {
if ([current containsObject:sublayer]) [restored addObject:sublayer];
}
for (CALayer *sublayer in current) {
if (![original containsObject:sublayer]) [restored addObject:sublayer];
}
Comment on lines +16 to +18
* That last line matters: this file reproduces a real bug with #677's exact
* signature, but not #677's exact setup. See PLAN-677.md for what that leaves
* open.
Comment on lines +108 to +112
* The reporter captures a vertical ScrollView with `snapshotContentContainer`,
* whose items carry the border. The minimal `border` card above shows the bug
* does not need any of that — but this card reproduces #677 as filed, so the
* fix is verified against the configuration actually reported and not only
* against our reduction of it.
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.

[iOS][Fabric] Content disappears when capturing a ScrollView item with borderWidth and snapshotContentContainer

2 participants