diff --git a/example/App.tsx b/example/App.tsx index 0cd12df6..59fb20d8 100644 --- a/example/App.tsx +++ b/example/App.tsx @@ -30,6 +30,7 @@ export type RootStackParamList = { // Rendering correctness Rendering: undefined; StyleFilters: undefined; + RenderInContext: undefined; }; // Import all test screens @@ -47,6 +48,7 @@ import FSTestScreen from './src/screens/FSTestScreen'; import ScrollViewTestScreen from './src/screens/ScrollViewTestScreen'; import RenderingTestScreen from './src/screens/RenderingTestScreen'; import StyleFiltersTestScreen from './src/screens/StyleFiltersTestScreen'; +import RenderInContextTestScreen from './src/screens/RenderInContextTestScreen'; const Stack = createStackNavigator(); @@ -152,6 +154,11 @@ function App(): React.JSX.Element { component={StyleFiltersTestScreen} options={{ title: '🎨 Style filters (Bug #578)' }} /> + ); diff --git a/example/e2e/helpers/pixels.js b/example/e2e/helpers/pixels.js new file mode 100644 index 00000000..457d9e34 --- /dev/null +++ b/example/e2e/helpers/pixels.js @@ -0,0 +1,151 @@ +/** + * Pixel-level helpers for E2E assertions on *captured images*. + * + * `snapshot-matcher.js` compares PNG byte sizes of `device.takeScreenshot()` + * output β€” that answers "does the screen still look the same", which is a + * different question from "is the image the library produced correct". + * These helpers answer the second one, by decoding the PNG that `captureRef` + * wrote and looking at actual pixels. + * + * Captures taken with `result: 'tmpfile'` land in the simulator's tmp + * directory, which is a plain path on the host filesystem, so the test process + * can read them directly. + */ + +const fs = require('fs'); +const { PNG } = require('pngjs'); +const pixelmatch = require('pixelmatch'); + +/** + * Decode a PNG from disk. + * + * @param {string} filePath - Absolute path, or a `file://` URI as returned by + * `captureRef({ result: 'tmpfile' })`. + * @returns {{width: number, height: number, data: Buffer}} RGBA, 4 bytes/px. + */ +function readPng(filePath) { + const path = filePath.startsWith('file://') + ? decodeURIComponent(filePath.slice('file://'.length)) + : filePath; + + if (!fs.existsSync(path)) { + throw new Error(`No capture file at ${path}`); + } + + return PNG.sync.read(fs.readFileSync(path)); +} + +/** + * Clamp a region to the image bounds so callers can describe regions in + * logical terms without worrying about the device scale factor. + */ +function clampRegion(png, region) { + // Clamp the origin from both sides: an out-of-bounds x/y would otherwise + // read past the buffer and turn every statistic into NaN, with uniqueColors + // collapsing to 1 β€” which reads exactly like "flat block", i.e. a false bug + // signal rather than an error. + const x = Math.min(Math.max(0, Math.floor(region.x)), png.width - 1); + const y = Math.min(Math.max(0, Math.floor(region.y)), png.height - 1); + const w = Math.max(1, Math.min(Math.floor(region.w), png.width - x)); + const h = Math.max(1, Math.min(Math.floor(region.h), png.height - y)); + return { x, y, w, h }; +} + +/** + * Describe the content of a rectangular region. + * + * `uniqueColors` is the discriminating one for #677: a region whose content + * has been painted over by an opaque layer collapses to a single color, so + * `uniqueColors === 1` means "flat block", i.e. the content is gone. + * + * Colors are quantized to 4 bits per channel so that anti-aliasing and + * subpixel text rendering don't inflate the count into meaninglessness β€” we + * want "is there anything drawn here at all", not a histogram. + * + * @param {{width:number,height:number,data:Buffer}} png + * @param {{x:number,y:number,w:number,h:number}} region + */ +function regionStats(png, region) { + const { x, y, w, h } = clampRegion(png, region); + const seen = new Set(); + let sumR = 0; + let sumG = 0; + let sumB = 0; + let nonWhite = 0; + let count = 0; + + for (let py = y; py < y + h; py++) { + for (let px = x; px < x + w; px++) { + const i = (png.width * py + px) << 2; + const r = png.data[i]; + const g = png.data[i + 1]; + const b = png.data[i + 2]; + + sumR += r; + sumG += g; + sumB += b; + count++; + + // "Not white" with a little slack for PNG rounding and blending. + if (r < 245 || g < 245 || b < 245) nonWhite++; + + seen.add(((r >> 4) << 8) | ((g >> 4) << 4) | (b >> 4)); + } + } + + return { + region: { x, y, w, h }, + pixels: count, + meanR: sumR / count, + meanG: sumG / count, + meanB: sumB / count, + uniqueColors: seen.size, + nonWhiteRatio: nonWhite / count, + }; +} + +/** The middle half of an image β€” where card content lives, away from borders. */ +function centerRegion(png) { + return { + x: Math.floor(png.width * 0.25), + y: Math.floor(png.height * 0.25), + w: Math.floor(png.width * 0.5), + h: Math.floor(png.height * 0.5), + }; +} + +/** + * Ratio of differing pixels between two captures (0 = identical, 1 = fully + * different). Returns 1 when the dimensions disagree, since that is already a + * complete mismatch. + * + * @param {object} a - PNG from `readPng` + * @param {object} b - PNG from `readPng` + * @param {{threshold?: number, diffPath?: string}} [options] - `diffPath` + * writes a visual diff image, useful when a CI failure needs explaining. + */ +function diffRatio(a, b, options = {}) { + const { threshold = 0.1, diffPath = null } = options; + + if (a.width !== b.width || a.height !== b.height) { + return 1; + } + + const diff = diffPath ? new PNG({ width: a.width, height: a.height }) : null; + const differing = pixelmatch( + a.data, + b.data, + diff ? diff.data : null, + a.width, + a.height, + { threshold }, + ); + + if (diff) { + fs.writeFileSync(diffPath, PNG.sync.write(diff)); + } + + return differing / (a.width * a.height); +} + +module.exports = { readPng, regionStats, centerRegion, diffRatio }; diff --git a/example/e2e/tests/render-in-context.test.js b/example/e2e/tests/render-in-context.test.js new file mode 100644 index 00000000..5fcd9d96 --- /dev/null +++ b/example/e2e/tests/render-in-context.test.js @@ -0,0 +1,268 @@ +/** + * #677 β€” `useRenderInContext` drops the content of bordered views (iOS/Fabric). + * + * Captures the same cards twice β€” once through `drawViewHierarchyInRect` (the + * default, known good) and once through `renderInContext:` β€” then reads the + * resulting PNGs off the simulator's tmp directory and compares actual pixels. + * + * Measured on iPhone 17 Pro / RN 0.84.1, before the fix: + * + * plain βœ“ border-less, stays on RN's CoreAnimation path + * border βœ• uniqueColors = 1 β€” captured as a flat white block + * border-clipped βœ“ `overflow: hidden` flips RN back to CoreAnimation + * nested-border βœ• the bordered view need not be the capture root + * scroll-border βœ“ the reporter's own configuration does NOT reproduce + * + * 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. + * + * The assertions deliberately do NOT use reference snapshots. What is being + * checked is an invariant ("both strategies must render the same static + * content", "a captured card is not a flat block"), which holds regardless of + * iOS version, device scale or font rendering. + */ + +// `expect` in this scope is Detox's element matcher. Value assertions need +// Jest's, which Detox's docs tell you to require explicitly. `expect` is a +// direct devDependency so this does not rely on hoisting out of jest's tree. +const { expect: jestExpect } = require('expect'); +const { + readPng, + regionStats, + centerRegion, + diffRatio, +} = require('../helpers/pixels'); + +const MODES = ['draw', 'ric']; +const CARDS = [ + 'plain', + 'border', + 'border-clipped', + 'nested-border', + 'scroll-border', +]; + +// Filled by beforeAll, keyed `${mode}-${card}`. +const uris = {}; + +describe('ViewShot - useRenderInContext (#677)', () => { + // `useRenderInContext` is iOS-only, and `result: 'tmpfile'` returns a path + // inside the emulator on Android, which this host-side process cannot read. + // jest.config testMatch picks this file up for both configurations, so the + // skip has to be explicit. + const isIOS = device.getPlatform() === 'ios'; + const itIOS = isIOS ? it : it.skip; + + beforeAll(async () => { + if (!isIOS) return; + + await device.launchApp({ + newInstance: true, + permissions: { photos: 'YES', camera: 'YES' }, + launchArgs: { detoxEnableSynchronization: 0 }, + }); + + await waitFor(element(by.text('πŸš€ React Native ViewShot'))) + .toBeVisible() + .withTimeout(60000); + + await navigateToScreen(); + + // Capture through both strategies. Each button captures all three cards. + // The buttons sit below the three cards, so they start off-screen. + for (const mode of MODES) { + await scrollIntoView(`ric-capture-${mode}`); + await element(by.id(`ric-capture-${mode}`)).tap(); + // The last card to be written tells us the whole batch is done. + await waitFor(element(by.id(`ric-uri-${mode}-scroll-border`))) + .toExist() + .withTimeout(30000); + } + + for (const mode of MODES) { + for (const card of CARDS) { + uris[`${mode}-${card}`] = await readUri(`ric-uri-${mode}-${card}`); + } + } + + console.log('πŸ“„ captured URIs:', JSON.stringify(uris, null, 2)); + }); + + /** + * Read the text of a `` node. This is how the test learns where the + * library wrote the PNG β€” `result: 'tmpfile'` returns a simulator path that + * is directly readable from the host. + */ + const readUri = async testID => { + const attributes = await element(by.id(testID)).getAttributes(); + const text = attributes.elements + ? attributes.elements[0].text + : attributes.text; + if (!text) throw new Error(`No URI text found for ${testID}`); + return text; + }; + + /** + * Bring a node of the test screen into view. The screen is taller than the + * viewport, so anything below the cards needs scrolling before Detox will + * accept a tap on it. + */ + const scrollIntoView = async testID => { + try { + await waitFor(element(by.id(testID))) + .toBeVisible() + .whileElement(by.id('renderInContextTestScrollView')) + .scroll(250, 'down'); + return; + } catch { + // Fall through to manual swipes. + } + + for (let i = 0; i < 8; i++) { + try { + await expect(element(by.id(testID))).toBeVisible(); + return; + } catch { + try { + await element(by.id('renderInContextTestScrollView')).swipe( + 'up', + 'slow', + 0.5, + ); + await new Promise(resolve => setTimeout(resolve, 400)); + } catch { + break; + } + } + } + }; + + const navigateToScreen = async () => { + const navTestId = 'nav-renderincontext'; + + for (let i = 0; i < 10; i++) { + try { + await expect(element(by.id(navTestId))).toBeVisible(); + break; + } catch { + try { + await element(by.id('homeScrollView')).swipe('up', 'slow', 0.4); + await new Promise(resolve => setTimeout(resolve, 400)); + } catch { + break; + } + } + } + + // Let scroll momentum settle before tapping. + await new Promise(resolve => setTimeout(resolve, 1000)); + try { + await element(by.id(navTestId)).tap(); + } catch { + await element(by.text('RenderInContext')).atIndex(0).tap(); + } + + await waitFor(element(by.id('renderInContextTestScrollView'))) + .toBeVisible() + .withTimeout(10000); + await new Promise(resolve => setTimeout(resolve, 1000)); + }; + + /** + * GUARD RAIL β€” must pass BOTH before and after the fix. + * + * The card with no border stays on RN's CoreAnimation path, so no extra + * sublayers are appended and `renderInContext:` has nothing to get wrong. + * If this fails, the tooling is broken (bad URI, unreadable PNG, wrong + * region) and every other result in this file is meaningless. + */ + itIOS( + 'renders the border-less card identically through both strategies', + () => { + const draw = readPng(uris['draw-plain']); + const ric = readPng(uris['ric-plain']); + + const stats = regionStats(ric, centerRegion(ric)); + jestExpect(stats.uniqueColors).toBeGreaterThan(1); + jestExpect(diffRatio(draw, ric)).toBeLessThan(0.02); + }, + ); + + /** + * THE BUG (#677). + * + * `borderWidth: 1` with an opaque color and no `overflow: hidden` makes + * `useCoreAnimationBorderRendering` false, so RN appends an opaque + * `_backgroundColorLayer` held behind the content only by `zPosition`. + * `renderInContext:` ignores `zPosition` and paints it last β€” over the text. + */ + itIOS('renders the bordered card identically through both strategies', () => { + const draw = readPng(uris['draw-border']); + const ric = readPng(uris['ric-border']); + + const stats = regionStats(ric, centerRegion(ric)); + + // The reported symptom, stated directly: the card came back as a flat + // block instead of the text it contains. + jestExpect(stats.uniqueColors).toBeGreaterThan(1); + + // And the stronger invariant: both strategies must agree on static content. + jestExpect(diffRatio(draw, ric)).toBeLessThan(0.02); + }); + + /** + * DIAGNOSTIC β€” tells the two failure modes apart. + * + * With `overflow: hidden`, RN takes the `createMaskLayer` branch instead of + * appending background sublayers. `renderInContext:` ignores `mask` too, but + * fixing that needs a different change than the zPosition ordering, so which + * of these two tests fails decides the shape of the fix. + */ + itIOS( + 'renders the clipped bordered card identically through both strategies', + () => { + const draw = readPng(uris['draw-border-clipped']); + const ric = readPng(uris['ric-border-clipped']); + + const stats = regionStats(ric, centerRegion(ric)); + jestExpect(stats.uniqueColors).toBeGreaterThan(1); + jestExpect(diffRatio(draw, ric)).toBeLessThan(0.02); + }, + ); + + /** + * Does the bordered view have to BE the capture root? The reporter's items + * are descendants, so if this passes while `border` fails, the reduction is + * not faithful to the report. + */ + itIOS('renders a bordered CHILD of the capture root identically', () => { + const draw = readPng(uris['draw-nested-border']); + const ric = readPng(uris['ric-nested-border']); + + const stats = regionStats(ric, centerRegion(ric)); + jestExpect(stats.uniqueColors).toBeGreaterThan(1); + jestExpect(diffRatio(draw, ric)).toBeLessThan(0.02); + }); + + /** + * #677 AS FILED β€” a vertical ScrollView captured with + * `snapshotContentContainer`, whose items carry the border. + * + * The `border` test above shows neither the ScrollView nor + * `snapshotContentContainer` is needed to trigger this. This one exists so + * the reported configuration itself is covered, rather than only our + * reduction of it. + */ + itIOS( + "renders the reporter's ScrollView case identically through both strategies", + () => { + const draw = readPng(uris['draw-scroll-border']); + const ric = readPng(uris['ric-scroll-border']); + + const stats = regionStats(ric, centerRegion(ric)); + jestExpect(stats.uniqueColors).toBeGreaterThan(1); + jestExpect(diffRatio(draw, ric)).toBeLessThan(0.02); + }, + ); +}); diff --git a/example/package-lock.json b/example/package-lock.json index 6dac48f5..86831d6f 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -45,8 +45,11 @@ "babel-jest": "^30.2.0", "detox": "^20.43.0", "eslint": "^8.19.0", + "expect": "^29.7.0", "jest": "^29.6.3", "jest-junit": "^16.0.0", + "pixelmatch": "^5.3.0", + "pngjs": "^7.0.0", "prettier": "2.8.8", "react-test-renderer": "19.2.3", "typescript": "^5.8.3" @@ -14843,6 +14846,29 @@ "node": ">= 6" } }, + "node_modules/pixelmatch": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/pixelmatch/-/pixelmatch-5.3.0.tgz", + "integrity": "sha512-o8mkY4E/+LNUf6LzX96ht6k6CEDi65k9G2rjMtBe9Oo+VPKSvl+0GKHuH/AlG+GA5LPG/i5hrekkxUc3s2HU+Q==", + "dev": true, + "license": "ISC", + "dependencies": { + "pngjs": "^6.0.0" + }, + "bin": { + "pixelmatch": "bin/pixelmatch" + } + }, + "node_modules/pixelmatch/node_modules/pngjs": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-6.0.0.tgz", + "integrity": "sha512-TRzzuFRRmEoSW/p1KVAmiOgPco2Irlah+bGFCeNfJXxxYGwSw7YwAOAcd7X28K/m5bjBWKsC29KyoMfHbypayg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.13.0" + } + }, "node_modules/pkg-dir": { "version": "4.2.0", "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", diff --git a/example/package.json b/example/package.json index b36c6095..1b512440 100644 --- a/example/package.json +++ b/example/package.json @@ -54,8 +54,11 @@ "babel-jest": "^30.2.0", "detox": "^20.43.0", "eslint": "^8.19.0", + "expect": "^29.7.0", "jest": "^29.6.3", "jest-junit": "^16.0.0", + "pixelmatch": "^5.3.0", + "pngjs": "^7.0.0", "prettier": "2.8.8", "react-test-renderer": "19.2.3", "typescript": "^5.8.3" diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index f794a87d..7b39016b 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -115,6 +115,15 @@ const testCases: { [category: string]: TestCase[] } = { priority: 'high', status: 'bug', }, + { + key: 'RenderInContext', + title: 'RenderInContext', + description: + 'useRenderInContext vs drawViewHierarchyInRect β€” zPosition (#677)', + emoji: '🩹', + priority: 'high', + status: 'tested', + }, ], '🟠 ADVANCED TESTS': [ // { diff --git a/example/src/screens/RenderInContextTestScreen.tsx b/example/src/screens/RenderInContextTestScreen.tsx new file mode 100644 index 00000000..d361a432 --- /dev/null +++ b/example/src/screens/RenderInContextTestScreen.tsx @@ -0,0 +1,406 @@ +import React, { useCallback, useRef, useState } from 'react'; +import { + View, + Text, + Image, + StyleSheet, + SafeAreaView, + ScrollView, + TouchableOpacity, + ViewStyle, +} from 'react-native'; +import { captureRef } from 'react-native-view-shot'; + +/** + * #677 β€” iOS `useRenderInContext` drops content of views that carry a border. + * + * `captureRef({ useRenderInContext: true })` calls `-[CALayer renderInContext:]`, + * which walks `sublayers` in array order and IGNORES `zPosition`. + * + * Under Fabric, `RCTViewComponentView` stops using `layer.backgroundColor` and + * appends a `_backgroundColorLayer` sublayer whenever it can't express the + * border with plain CoreAnimation properties β€” see + * `RCTViewComponentView.mm`, `useCoreAnimationBorderRendering`. That layer is + * kept behind the content purely by `zPosition = -1024` + * (`BACKGROUND_COLOR_ZPOSITION`), which the live compositor honours and + * `renderInContext:` does not. Appended last, it is therefore painted last β€” + * over the text β€” and the card comes back as a flat white block. + * + * The cards below carry identical content and differ only in what decides + * which rendering path RN takes. Measured before the fix: + * + * - `plain` no border β†’ fine + * - `border` borderWidth: 1 β†’ captured as a flat block + * - `border-clipped` border + overflow: hidden β†’ fine (clipsToBounds flips + * `useCoreAnimationBorderRendering` + * back to true) + * - `nested-border` border on a child of the capture root β†’ flat block too + * - `scroll-border` the reporter's own setup β†’ does NOT reproduce + * + * The last two are controls. `nested-border` rules out "the bordered view must + * be the capture root". `scroll-border` replicates #677 as filed, and passes β€” + * so what is reproduced here shares #677's signature without being provably + * the same instance of it. + */ + +const CARD_WIDTH = 300; +const CARD_HEIGHT = 120; + +type ModeKey = 'draw' | 'ric'; + +const MODES: { key: ModeKey; label: string; useRenderInContext: boolean }[] = [ + { + key: 'draw', + label: 'drawViewHierarchyInRect', + useRenderInContext: false, + }, + { key: 'ric', label: 'renderInContext', useRenderInContext: true }, +]; + +type CardKey = + | 'plain' + | 'border' + | 'border-clipped' + | 'nested-border' + | 'scroll-border'; + +interface CardSpec { + key: CardKey; + label: string; + style: ViewStyle; + /** Rendered as a ScrollView captured with `snapshotContentContainer`. */ + scroll?: boolean; + /** The bordered view is a child of the capture root, not the root itself. */ + nested?: boolean; +} + +const CARDS: CardSpec[] = [ + { + key: 'plain', + label: 'no border (reference)', + style: {}, + }, + { + key: 'border', + label: 'borderWidth: 1 β€” minimal repro', + style: { borderWidth: 1, borderColor: '#F6F6F6' }, + }, + { + key: 'border-clipped', + label: "borderWidth: 1 + overflow: 'hidden'", + style: { borderWidth: 1, borderColor: '#F6F6F6', overflow: 'hidden' }, + }, + { + key: 'nested-border', + label: 'borderWidth: 1 on a CHILD of the capture root', + style: {}, + nested: true, + }, + { + key: 'scroll-border', + label: "the reporter's exact case β€” ScrollView + snapshotContentContainer", + style: {}, + scroll: true, + }, +]; + +/** + * 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. + */ +const SCROLL_ITEMS = [1, 2, 3, 4]; + +/** + * Identical in every card. High-contrast black on white, covering a large part + * of the surface, so that "the content vanished" is measurable rather than a + * judgement call. + */ +const CardContent: React.FC = () => ( + <> + ABCDEFGHIJ + 0123456789 + KLMNOPQRST + +); + +type Results = Partial>; +type Errors = Partial>; + +const RenderInContextTestScreen: React.FC = () => { + const refs = useRef>>({}); + const [results, setResults] = useState({}); + const [errors, setErrors] = useState({}); + const [capturing, setCapturing] = useState(null); + + const captureAll = useCallback(async (mode: ModeKey) => { + const modeConfig = MODES.find(m => m.key === mode); + if (!modeConfig) return; + + setCapturing(mode); + for (const card of CARDS) { + const slot = `${mode}-${card.key}` as `${ModeKey}-${CardKey}`; + const node = refs.current[card.key]; + if (!node) continue; + + try { + const uri = await captureRef(node, { + format: 'png', + quality: 1, + result: 'tmpfile', + useRenderInContext: modeConfig.useRenderInContext, + // The reporter's card is a ScrollView captured in full. + snapshotContentContainer: card.scroll === true, + }); + setResults(prev => ({ ...prev, [slot]: uri })); + setErrors(prev => ({ ...prev, [slot]: undefined })); + } catch (error: any) { + setErrors(prev => ({ + ...prev, + [slot]: String(error?.message ?? error), + })); + } + } + setCapturing(null); + }, []); + + return ( + + + + 🩹 useRenderInContext (#677) + + On iOS, `useRenderInContext: true` swaps `drawViewHierarchyInRect` + for `CALayer.renderInContext:`, which walks sublayers in array order + and ignores `zPosition`. + + + Fabric relies on `zPosition = -1024` to keep the background layer it + appends for bordered views behind the content. Captured through + `renderInContext:`, that layer paints last instead β€” over the text. + + + Capture both ways: the three cards should look identical in both + columns. Any card that comes back as a blank block is the bug. + + + + {/* The capture targets. Identical content, styles differ only in the + border/overflow combination that flips RN's rendering path. */} + + {CARDS.map(card => ( + + {card.label} + {card.nested ? ( + { + refs.current[card.key] = node; + }} + collapsable={false} + style={styles.card} + > + + + + + ) : card.scroll ? ( + { + refs.current[card.key] = node; + }} + collapsable={false} + removeClippedSubviews={false} + style={styles.scrollCard} + > + + {SCROLL_ITEMS.map(n => ( + + {`ITEM ${n}`} + + ))} + + + ) : ( + { + refs.current[card.key] = node; + }} + collapsable={false} + style={[styles.card, card.style]} + > + + + )} + + ))} + + + + {MODES.map(mode => ( + captureAll(mode.key)} + testID={`ric-capture-${mode.key}`} + accessible={true} + accessibilityLabel={`ric-capture-${mode.key}`} + > + + {capturing === mode.key ? 'πŸ“Έ Capturing…' : `πŸ“Έ ${mode.label}`} + + + ))} + + + {/* Results. The URI Text nodes are what the Detox test reads via + getAttributes() to locate the PNG on the host filesystem. */} + {MODES.map(mode => ( + + {mode.label} + {CARDS.map(card => { + const slot = `${mode.key}-${card.key}` as `${ModeKey}-${CardKey}`; + const uri = results[slot]; + const error = errors[slot]; + return ( + + {card.key} + {uri ? ( + <> + + + {uri} + + + ) : error ? ( + + {error} + + ) : ( + not captured yet + )} + + ); + })} + + ))} + + + ); +}; + +const styles = StyleSheet.create({ + container: { flex: 1, backgroundColor: '#F2F2F7' }, + scroll: { flex: 1 }, + intro: { padding: 16 }, + introTitle: { + fontSize: 18, + fontWeight: '700', + color: '#333', + marginBottom: 8, + }, + introBody: { fontSize: 13, color: '#555', lineHeight: 19, marginBottom: 6 }, + + cards: { paddingHorizontal: 16 }, + cardSlot: { marginBottom: 16 }, + cardLabel: { fontSize: 12, color: '#666', marginBottom: 6 }, + card: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + backgroundColor: '#FFFFFF', + justifyContent: 'center', + paddingHorizontal: 12, + }, + scrollCard: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + backgroundColor: '#FFFFFF', + }, + nestedInner: { + flex: 1, + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#F6F6F6', + justifyContent: 'center', + paddingHorizontal: 12, + }, + // The reporter's item style, verbatim. + scrollItem: { + backgroundColor: '#FFFFFF', + borderWidth: 1, + borderColor: '#F6F6F6', + paddingHorizontal: 12, + paddingVertical: 10, + }, + cardLine: { + fontSize: 26, + fontWeight: '700', + color: '#000000', + letterSpacing: 2, + }, + + buttons: { paddingHorizontal: 16, marginTop: 8 }, + button: { + backgroundColor: '#007AFF', + paddingVertical: 14, + borderRadius: 12, + marginBottom: 10, + }, + buttonDisabled: { backgroundColor: '#999' }, + buttonText: { + color: '#FFFFFF', + fontSize: 15, + fontWeight: '600', + textAlign: 'center', + }, + + resultSection: { paddingHorizontal: 16, marginTop: 12 }, + resultTitle: { + fontSize: 15, + fontWeight: '700', + color: '#333', + marginBottom: 8, + }, + resultRow: { + backgroundColor: '#FFFFFF', + borderRadius: 10, + padding: 10, + marginBottom: 10, + }, + resultLabel: { + fontSize: 12, + fontWeight: '600', + color: '#444', + marginBottom: 6, + }, + preview: { + width: CARD_WIDTH, + height: CARD_HEIGHT, + backgroundColor: '#EEEEEE', + }, + uri: { fontSize: 9, color: '#888', marginTop: 6 }, + error: { fontSize: 12, color: '#C0392B' }, + pending: { fontSize: 12, color: '#AAA', fontStyle: 'italic' }, +}); + +export default RenderInContextTestScreen; diff --git a/ios/RNViewShot.mm b/ios/RNViewShot.mm index 820b7f33..43860e13 100644 --- a/ios/RNViewShot.mm +++ b/ios/RNViewShot.mm @@ -17,6 +17,81 @@ #import #endif +/** + * `-[CALayer renderInContext:]` paints sublayers in `sublayers` array order and + * ignores `zPosition`, which the live compositor honours. Fabric relies 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 (#677). + * + * Reordering each `sublayers` array to match z-order before rendering restores + * what the compositor would have drawn. The sort is stable, so layers sharing a + * zPosition keep their array order, which is exactly Core Animation's own rule. + * + * Layers that were reordered are collected into `mutated`/`originals` so the + * caller can put the tree back, whatever happens during rendering. + */ +static void RNViewShotSortSublayersByZPosition(CALayer *layer, + NSMutableArray *mutated, + NSMutableArray *> *originals) +{ + NSArray *sublayers = layer.sublayers; + if (sublayers.count > 1) { + NSArray *sorted = [sublayers sortedArrayWithOptions:NSSortStable + usingComparator:^NSComparisonResult(CALayer *a, CALayer *b) { + if (a.zPosition < b.zPosition) return NSOrderedAscending; + if (a.zPosition > b.zPosition) return NSOrderedDescending; + return NSOrderedSame; + }]; + // Only touch the tree where the order actually differs: the vast majority + // of layers are already in z-order and must be left untouched. + if (![sorted isEqualToArray:sublayers]) { + [mutated addObject:layer]; + [originals addObject:sublayers]; + layer.sublayers = sorted; + } + } + + for (CALayer *sublayer in layer.sublayers) { + RNViewShotSortSublayersByZPosition(sublayer, mutated, originals); + } +} + +/** + * Put each reordered array back the way it was. + * + * `renderInContext:` drives `display` / `drawInContext:` on layers that need + * it, so a delegate could add or remove a sublayer while we are rendering. + * Assigning the pre-render snapshot back wholesale would drop such an addition + * or resurrect a removal, corrupting the live view. So the current array is + * reordered to the recorded order instead: layers no longer present are simply + * never re-added, and layers added meanwhile keep their relative order at the + * end, where `addSublayer:` would have put them. + */ +static void RNViewShotRestoreSublayers(NSArray *mutated, + NSArray *> *originals) +{ + for (NSUInteger i = 0; i < mutated.count; i++) { + CALayer *layer = mutated[i]; + NSArray *original = originals[i]; + NSArray *current = layer.sublayers; + + if ([current isEqualToArray:original]) continue; + + NSMutableArray *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]; + } + layer.sublayers = restored; + } +} + @implementation RNViewShot RCT_EXPORT_MODULE() @@ -155,7 +230,19 @@ - (dispatch_queue_t)methodQueue UIImage *image = [renderer imageWithActions:^(UIGraphicsImageRendererContext * _Nonnull rendererContext) { if (renderInContext) { // this comes with some trade-offs such as inability to capture gradients or scrollview's content in full but it works for large views - [rendered.layer renderInContext:rendererContext.CGContext]; + NSMutableArray *mutated = [NSMutableArray new]; + NSMutableArray *> *originals = [NSMutableArray new]; + // Actions are disabled so the reorder and its undo cannot animate, and + // the whole thing is one transaction so nothing is ever presented. + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + @try { + RNViewShotSortSublayersByZPosition(rendered.layer, mutated, originals); + [rendered.layer renderInContext:rendererContext.CGContext]; + } @finally { + RNViewShotRestoreSublayers(mutated, originals); + [CATransaction commit]; + } success = YES; } else {