From 874684da39a8cc022b4d7ce22a35b8494255129c Mon Sep 17 00:00:00 2001 From: gre Date: Sat, 5 Sep 2026 12:19:35 +0200 Subject: [PATCH 1/3] =?UTF-8?q?test(ios):=20reproduce=20#677=20=E2=80=94?= =?UTF-8?q?=20useRenderInContext=20drops=20bordered=20view=20content?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PLAN-677.md | 323 ++++++++++++++++++ example/App.tsx | 7 + example/e2e/helpers/pixels.js | 147 ++++++++ example/e2e/tests/render-in-context.test.js | 204 +++++++++++ example/package-lock.json | 25 ++ example/package.json | 2 + example/src/screens/HomeScreen.tsx | 9 + .../src/screens/RenderInContextTestScreen.tsx | 310 +++++++++++++++++ 8 files changed, 1027 insertions(+) create mode 100644 PLAN-677.md create mode 100644 example/e2e/helpers/pixels.js create mode 100644 example/e2e/tests/render-in-context.test.js create mode 100644 example/src/screens/RenderInContextTestScreen.tsx diff --git a/PLAN-677.md b/PLAN-677.md new file mode 100644 index 00000000..3c14b942 --- /dev/null +++ b/PLAN-677.md @@ -0,0 +1,323 @@ +# Plan — Issue #677 : contenu blanc sous `useRenderInContext` + `borderWidth` (iOS/Fabric) + +> Approche TDD : on écrit d'abord un test qui **échoue** en reproduisant le bug, +> puis on corrige. Aucune étape de fix n'est entamée avant que l'étape 3 soit rouge +> pour la bonne raison. + +--- + +## 0. État des lieux (vérifié, 2026-09-05) + +### Ce qu'on sait de source sûre + +| Fait | Preuve | +| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Aucune PR ouverte ne corrige ça | Sur les 20 PRs, seule #683 touche `ios/RNViewShot.mm`, et uniquement `releaseCapture`. | +| `useRenderInContext` n'est **jamais** exercé | 4 occurrences dans tout le repo : `README.md:150`, `CLAUDE.md:54`, `ios/RNViewShot.mm:73`, `src/index.tsx:75`. Zéro écran, zéro test E2E. | +| `ScrollViewTestScreen` ne couvre pas le cas | Il utilise `snapshotContentContainer: true` mais **sans** `useRenderInContext`, et ses `borderWidth: 1` sont sur le wrapper externe (`captureArea`) et `previewImage`, **pas sur les items dans le contenu scrollé** (`colorItem` n'a aucune bordure). | +| iOS ne supporte pas `format: 'raw'` | `src/index.tsx:112` — `raw` est concaténé seulement si `Platform.OS === "android"`. Donc **pas d'accès aux pixels depuis le JS sur iOS**. | +| Le comparateur E2E actuel est inutilisable ici | `example/e2e/helpers/snapshot-matcher.js` compare la **taille en octets** de PNG avec 5 % de tolérance, sur un `device.takeScreenshot()` (écran, pas la capture). Ne peut pas détecter ce bug. | + +### Mécanisme (lu dans `example/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm`, RN 0.84.1) + +1. **`:968`** — `useCoreAnimationBorderRendering` est vrai seulement si la bordure est uniforme **et** (`borderWidths.left == 0` **ou** `clipsToBounds` **ou** bordure transparente). + + Le style du rapporteur (`borderWidth: 1`, `borderColor: '#F6F6F6'` opaque, **pas de `overflow: hidden`**) fait basculer ce booléen à **`false`**. + +2. **`:994`** — dans ce cas RN abandonne `layer.backgroundColor` et **ajoute des sous-couches** : + + ```objc + layer.backgroundColor = nil; + _backgroundColorLayer = [CALayer layer]; + _backgroundColorLayer.zPosition = BACKGROUND_COLOR_ZPOSITION; // = -1024.0f (:37) + [layer addSublayer:_backgroundColorLayer]; + ``` + + Idem pour `_borderLayer` (`:1016`, zPosition `-1023`). + +3. RN compte sur `zPosition = -1024` pour que ces calques restent **derrière** les sous-vues. Le compositeur Core Animation respecte `zPosition` → **correct à l'écran**. + +4. **`CALayer renderInContext:` ignore `zPosition`** et dessine les sous-couches dans l'ordre du tableau `sublayers`. Le calque de fond, ajouté en dernier par `addSublayer:`, est donc **peint par-dessus le texte**. + + → _« The missing area is rendered as an empty white block »_, et la couleur correspond : leur `content` est `backgroundColor: '#FFFFFF'`. + +5. Leur workaround (bordure déportée sur un wrapper en `backgroundColor` + `padding`) garde toutes les vues en mode CoreAnimation → aucune sous-couche ajoutée → ça marche. + +**Corollaires importants pour le plan :** + +- `snapshotContentContainer` et le ScrollView ne sont **pas** nécessaires au bug. Ce sont des circonstances, pas des causes. Le repro minimal est **une View avec bordure + du texte, capturée avec `useRenderInContext: true`**. C'est ce qu'on teste. +- Le bug est **indépendant du fait que ce soit du texte** : n'importe quel enfant est masqué. +- `drawViewHierarchyInRect` (le défaut) passe par le render server et respecte `zPosition` → non affecté. Le test doit donc capturer **les deux** et les comparer. + +### Incertitude assumée + +Le rapporteur n'a pas montré `overflow: hidden`. S'il l'avait, on tomberait sur une autre branche +(`:1253`, masque `CAShapeLayer`, dont `renderInContext:` ne gère pas non plus le rendu) qui produit +un symptôme voisin par un chemin différent. **L'étape 3 tranche** : on teste les deux variantes. + +### Partie « shadow lente » de l'issue + +`snapshotContentContainer` redimensionne `scrollView.frame` à la taille totale du contenu +(`ios/RNViewShot.mm`, bloc « Save scroll & frame »), ce qui force un layout synchrone de tous les +items ; chacun régénère son image de box-shadow (boucle `_boxShadowLayers`, `RCTViewComponentView.mm:1200`). +C'est du O(N) côté RN, **pas** côté view-shot. + +→ **Hors scope de ce plan.** À traiter comme une note dans la réponse à l'issue, pas comme un fix. + +--- + +## 1. Ajouter l'outillage de comparaison de pixels + +Le blocage principal : il n'existe aujourd'hui aucun moyen d'asserter sur le **contenu d'une capture**. + +```bash +cd example +npm i -D pngjs pixelmatch +``` + +Créer `example/e2e/helpers/pixels.js` : + +- `readPng(filePath)` → `{ width, height, data }` via `pngjs`. +- `regionStats(png, {x, y, w, h})` → `{ meanR, meanG, meanB, uniqueColors, nonWhiteRatio }`. +- `diffRatio(pngA, pngB)` → via `pixelmatch`, ratio de pixels différents (0..1). + +> Note : ce helper est **complémentaire**, pas un remplacement de `snapshot-matcher.js`. +> On ne touche pas à l'existant dans ce plan — les 9 snapshots de référence par plateforme +> restent valides. (Refondre `compareImages` en vraie comparaison pixel est un chantier +> séparé qui mérite sa propre PR.) + +**Comment le test accède au fichier :** avec `result: 'tmpfile'`, `captureRef` renvoie un chemin +dans le tmp du simulateur, qui est **directement lisible depuis le host macOS**. L'écran affiche +l'URI dans un ``, et Detox le récupère via `getAttributes()` +(`.text`). Pattern non encore utilisé dans ce repo — à valider tôt (voir étape 3, garde-fou). + +--- + +## 2. Écrire l'écran de repro + +**Nouveau fichier :** `example/src/screens/RenderInContextTestScreen.tsx` + +Ne pas greffer ça sur `ScrollViewTestScreen` : le sujet est `useRenderInContext`, pas le scroll. +Un écran dédié documente une option publique aujourd'hui totalement non testée. + +### Contenu + +Trois cartes, **volontairement identiques en contenu**, ne différant que par le style qui +déclenche (ou non) le basculement `useCoreAnimationBorderRendering` : + +| testID | Style | Mode RN attendu | +| ------------------------- | -------------------------------------------- | ---------------------------------------- | +| `ric-card-plain` | `backgroundColor: '#FFFFFF'`, aucune bordure | CoreAnimation → OK | +| `ric-card-border` | `+ borderWidth: 1, borderColor: '#F6F6F6'` | **couches ajoutées → bug** | +| `ric-card-border-clipped` | `+ borderWidth: 1 + overflow: 'hidden'` | branche masque — départage l'incertitude | + +Chaque carte contient du **texte noir sur fond blanc**, bien contrasté et couvrant une large +part de la surface (c'est ce qui rend la disparition mesurable). Taille fixe et connue, +p.ex. 300×120, pour que les régions testées soient déterministes. + +### Contrôles + +- Bouton `ric-capture-drawhierarchy` → capture les 3 cartes avec `useRenderInContext: false` +- Bouton `ric-capture-renderincontext` → idem avec `useRenderInContext: true` +- Options communes : `{ format: 'png', quality: 1, result: 'tmpfile' }` +- Chaque résultat expose : + - `{uri}` + - un `` pour l'inspection visuelle manuelle + +### Enregistrement + +- `example/App.tsx` : ajouter `RenderInContext: undefined` à `RootStackParamList`, l'import, + et le ``. +- `example/src/screens/HomeScreen.tsx` : entrée dans la catégorie `🔴 RENDERING CORRECTNESS` + (à côté de `StyleFilters`), avec `status: 'bug'` : + + ```ts + { + key: 'RenderInContext', + title: 'RenderInContext', + description: 'useRenderInContext + borderWidth — content disappears (#677)', + emoji: '🩹', + priority: 'high', + status: 'bug', + } + ``` + + → le testID de navigation devient `nav-renderincontext` + (pattern `nav-${title.toLowerCase().replace(/\s+/g,'-')}`, `HomeScreen.tsx:183`). + +--- + +## 3. Écrire le test qui échoue ⛔ **← le cœur du TDD** + +**Nouveau fichier :** `example/e2e/tests/render-in-context.test.js` + +Structure calquée sur `snapshot-content-container.test.js` (helper `goBackToHome`, navigation +par testID avec fallback texte, `launchArgs: { detoxEnableSynchronization: 0 }`). + +### Assertions, de la plus robuste à la plus fine + +L'assertion **ne doit pas** dépendre d'un PNG de référence : c'est une propriété invariante, +pas un pixel-perfect. On compare les deux modes entre eux. + +``` +Pour chaque carte C : + uriA = capture(C, useRenderInContext: false) // référence connue-bonne + uriB = capture(C, useRenderInContext: true) + + 1. ASSERTION PRINCIPALE (indépendante de la plateforme) + regionStats(B, centre).uniqueColors > 1 + → « la carte capturée n'est pas un aplat uni » + C'est exactement le symptôme rapporté : bloc blanc vide. + + 2. ASSERTION DE NON-RÉGRESSION CROISÉE + diffRatio(A, B) < 0.02 + → les deux stratégies doivent produire le même rendu pour du contenu statique + + 3. GARDE-FOU (doit passer AVANT et APRÈS le fix) + Sur `ric-card-plain` : les deux modes matchent déjà. + Si celui-ci échoue, c'est l'outillage qui est cassé, pas la lib. +``` + +### Résultat attendu à cette étape + +``` +✅ ric-card-plain — les deux modes concordent +❌ ric-card-border — mode renderInContext = aplat uni ← LE BUG +? ric-card-border-clipped — départage la branche masque +``` + +**Ne pas passer à l'étape 4 tant que :** + +- le garde-fou (3) ne passe pas — sinon on chasse un fantôme d'outillage ; +- `ric-card-border` n'échoue pas — sinon on n'a pas reproduit #677. + +Vérifier aussi visuellement le `ric-preview-*` correspondant : le bloc blanc doit être +**visible à l'œil** dans l'app. Si le test est rouge mais que l'aperçu est correct, c'est +l'assertion qui est mauvaise. + +### Si ça ne reproduit pas + +Ne pas forcer l'assertion. Explorer dans cet ordre : + +1. Vérifier que le simulateur tourne bien en **Fabric** (le bug est spécifique au nouveau + moteur — Paper dessinait les bordures dans `layer.contents`, sans sous-couche ajoutée). +2. Ajouter une variante avec `borderRadius` non uniforme (autre déclencheur de la même branche). +3. Ajouter une variante avec `boxShadow` (crée aussi des sous-couches, `:1200`). +4. En dernier recours, dumper l'arbre de calques avec un `RCT_EXPORT_METHOD` de debug + temporaire pour confirmer la présence de `_backgroundColorLayer`. + +--- + +## 4. Corriger + +⚠️ **Ne rien écrire ici avant que l'étape 3 soit rouge pour la bonne raison.** +Le choix ci-dessous dépend de ce que l'étape 3 révèle, notamment du sort de +`ric-card-border-clipped`. + +### Option A — rendu manuel trié par `zPosition` (le vrai fix) + +Dans `ios/RNViewShot.mm`, remplacer l'appel unique + +```objc +[rendered.layer renderInContext:rendererContext.CGContext]; +``` + +par une descente récursive qui, à chaque niveau, trie `layer.sublayers` par `zPosition` +(tri **stable**, pour préserver l'ordre du tableau à zPosition égale — c'est la sémantique +de Core Animation) avant de rendre chaque sous-couche dans son propre espace de coordonnées. + +- ✅ Corrige la classe entière de bugs, pas juste `borderWidth` : shadows, backgrounds + non uniformes, tout ce qui repose sur `zPosition`. +- ❌ Réimplémente une partie du compositeur : `mask`, `masksToBounds`, `transform`, + `shouldRasterize`, `opacity` composé. Risque réel de régression sur des cas aujourd'hui OK. +- ❌ Ne corrige **pas** la branche masque (`:1253`) — `renderInContext:` ignore aussi `mask`. + +### Option B — gérer uniquement `zPosition`, garder `renderInContext` sinon + +Trier les sous-couches par `zPosition` **sans** réimplémenter le rendu : réordonner +temporairement le tableau `sublayers` autour de l'appel, puis restaurer. + +- ✅ Beaucoup plus petit, et cible précisément le mécanisme identifié. +- ❌ Mute l'arbre de calques pendant la capture — à faire strictement sur le thread UI et à + restaurer dans tous les cas (y compris exception). Peut provoquer un flash visible. +- ❌ Ne couvre toujours pas la branche masque. + +### Option C — documenter, ne pas corriger + +Si A et B s'avèrent trop risquées, l'écran + le test restent le livrable de valeur : ils +transforment un rapport flou en gap **connu, reproductible et surveillé**. + +> Cf. le précédent `StyleFilters` (#578) : écran avec `status: 'bug'`, pas de fix. +> Une note courte suffit — pas de doc de troubleshooting multi-paragraphes. + +**Recommandation :** viser **B**, avec A en repli si B ne suffit pas. Décider après l'étape 3. + +--- + +## 5. Marquer le test comme attendu-vert + +Une fois le fix en place : + +- Retirer tout `.failing` / `.skip` posé à l'étape 3. +- Faire tourner **l'ensemble** de la suite iOS, pas seulement le nouveau test — l'Option A + ou B touche le chemin de rendu partagé, et les 9 snapshots de référence iOS existants + sont le filet de sécurité. +- Basculer l'entrée HomeScreen de `status: 'bug'` à `status: 'tested'`. + +```bash +cd example +npm run build:e2e:ios +npm run test:e2e:ios +``` + +⚠️ **Ne pas** lancer `UPDATE_SNAPSHOTS=true` par réflexe si des références bougent : +sur ce fix précis, une référence qui change est soit une vraie correction (à valider à l'œil, +image avant/après), soit une régression. Regarder avant d'écraser. + +--- + +## 6. Répondre à l'issue + +**À faire seulement après validation, et à me faire relire avant post** (aucune action publique +sans feu vert explicite). + +Contenu utile, court : + +- Cause confirmée, avec le pointeur exact : `RCTViewComponentView.mm:968` et `:994`, + `BACKGROUND_COLOR_ZPOSITION = -1024`, et le fait que `renderInContext:` ignore `zPosition`. +- Sa demande de repro minimal devient inutile : on a le mécanisme et un écran dédié. +- Confirmer que son workaround est correct **et expliquer pourquoi** (il garde les vues en + mode CoreAnimation) — c'est ce qui lui permettra de généraliser à d'autres styles. +- La lenteur des shadows est distincte : layout O(N) déclenché par le resize de frame de + `snapshotContentContainer`, côté RN. Le mentionner, ne pas le traiter ici. + +--- + +## Ordre d'exécution et interaction avec les PRs + +Ce chantier touche `ios/RNViewShot.mm` (chemin de **capture**), `example/App.tsx`, +`example/src/screens/HomeScreen.tsx`, plus des fichiers neufs. + +- **#683** touche `ios/RNViewShot.mm` mais uniquement `releaseCapture` → zones disjointes, + pas de conflit réel, mais git le signalera peut-être. Merger #683 **avant** d'attaquer + l'étape 4 évite d'avoir à arbitrer. +- **#685** et **#692** touchent `example/src/screens/` → aucun chevauchement de fichier + avec les nôtres. +- Aucune PR ne touche `App.tsx` ni `HomeScreen.tsx` → l'étape 2 est sûre dès maintenant. + +**Les étapes 1 à 3 sont indépendantes de tout le backlog de PRs et peuvent démarrer +immédiatement.** + +--- + +## Récapitulatif des fichiers + +| Fichier | Action | +| --------------------------------------------------- | ---------------------------------------------- | +| `example/package.json` | + `pngjs`, `pixelmatch` en devDeps | +| `example/e2e/helpers/pixels.js` | **créer** | +| `example/src/screens/RenderInContextTestScreen.tsx` | **créer** | +| `example/App.tsx` | + route `RenderInContext` | +| `example/src/screens/HomeScreen.tsx` | + entrée dans `🔴 RENDERING CORRECTNESS` | +| `example/e2e/tests/render-in-context.test.js` | **créer** — doit être ROUGE d'abord | +| `ios/RNViewShot.mm` | fix (étape 4) — **pas avant que 3 soit rouge** | 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..2646e0f5 --- /dev/null +++ b/example/e2e/helpers/pixels.js @@ -0,0 +1,147 @@ +/** + * 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) { + const x = Math.max(0, Math.floor(region.x)); + const y = Math.max(0, Math.floor(region.y)); + 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..70d6f1a3 --- /dev/null +++ b/example/e2e/tests/render-in-context.test.js @@ -0,0 +1,204 @@ +/** + * #677 — `useRenderInContext` drops the content of bordered views (iOS/Fabric). + * + * ⚠️ THIS TEST IS EXPECTED TO FAIL until the fix lands. It is the TDD + * reproduction: it asserts the behaviour we want, against a library that + * currently does not provide it. See PLAN-677.md, step 3. + * + * What it does: captures the same three 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. + * + * 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. +const { expect: jestExpect } = require('expect'); +const { + readPng, + regionStats, + centerRegion, + diffRatio, +} = require('../helpers/pixels'); + +const MODES = ['draw', 'ric']; +const CARDS = ['plain', 'border', 'border-clipped']; + +// Filled by beforeAll, keyed `${mode}-${card}`. +const uris = {}; + +describe('ViewShot - useRenderInContext (#677)', () => { + beforeAll(async () => { + 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}-border-clipped`))) + .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. + */ + it('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. + */ + it('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. + */ + it('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); + }); +}); diff --git a/example/package-lock.json b/example/package-lock.json index 6dac48f5..fb2f8053 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -47,6 +47,8 @@ "eslint": "^8.19.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 +14845,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..53c993f8 100644 --- a/example/package.json +++ b/example/package.json @@ -56,6 +56,8 @@ "eslint": "^8.19.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..d1052f7e 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 + borderWidth — content disappears (#677)', + emoji: '🩹', + priority: 'high', + status: 'bug', + }, ], '🟠 ADVANCED TESTS': [ // { diff --git a/example/src/screens/RenderInContextTestScreen.tsx b/example/src/screens/RenderInContextTestScreen.tsx new file mode 100644 index 00000000..7291ef29 --- /dev/null +++ b/example/src/screens/RenderInContextTestScreen.tsx @@ -0,0 +1,310 @@ +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 three cards below carry identical content and differ only in the style + * that decides which rendering path RN takes: + * + * - `plain` no border → CoreAnimation path, expected fine + * - `border` borderWidth: 1 → appended sublayers, expected broken + * - `borderClipped` border + overflow → mask path (`createMaskLayer`), which + * `renderInContext:` also ignores + * + * The third card exists to tell those two failure modes apart: the reporter + * never showed whether their container clipped, and the two branches need + * different fixes. + */ + +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'; + +const CARDS: { key: CardKey; label: string; style: ViewStyle }[] = [ + { + key: 'plain', + label: 'no border (reference)', + style: {}, + }, + { + key: 'border', + label: "borderWidth: 1 — the reporter's case", + style: { borderWidth: 1, borderColor: '#F6F6F6' }, + }, + { + key: 'border-clipped', + label: "borderWidth: 1 + overflow: 'hidden'", + style: { borderWidth: 1, borderColor: '#F6F6F6', overflow: 'hidden' }, + }, +]; + +/** + * 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, + }); + 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} + { + 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, + }, + 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; From 6fb98c10bbbbcf536bc3d10cc5e8b9e8b41413c4 Mon Sep 17 00:00:00 2001 From: gre Date: Sat, 5 Sep 2026 13:00:22 +0200 Subject: [PATCH 2/3] fix(ios): honour zPosition when capturing with useRenderInContext MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `-[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 --- PLAN-677.md | 46 +++++- example/e2e/tests/render-in-context.test.js | 63 ++++++-- .../src/screens/RenderInContextTestScreen.tsx | 142 +++++++++++++++--- ios/RNViewShot.mm | 65 +++++++- 4 files changed, 282 insertions(+), 34 deletions(-) diff --git a/PLAN-677.md b/PLAN-677.md index 3c14b942..bf8e979b 100644 --- a/PLAN-677.md +++ b/PLAN-677.md @@ -208,6 +208,47 @@ Ne pas forcer l'assertion. Explorer dans cet ordre : --- +## 3bis. Résultats mesurés (2026-09-05, iPhone 17 Pro, RN 0.84.1) + +Étape 3 exécutée. Mesures au pixel, avant fix : + +| carte | uniqueColors (ric) | diff draw↔ric | verdict | +| ---------------- | ------------------ | -------------- | ------------------------------------------- | +| `plain` | 16 | 0.0000 | ✓ garde-fou | +| `border` | **1** | **0.1092** | ✕ **bug reproduit** | +| `border-clipped` | 16 | 0.0000 | ✓ non affectée | +| `nested-border` | — | — | ✕ casse aussi | +| `scroll-border` | 16 | < 0.02 | ✓ **le cas du rapporteur NE reproduit PAS** | + +Trois conclusions : + +1. **La branche masque est hors de cause.** `overflow: hidden` rebascule + `useCoreAnimationBorderRendering` à `true` (`RCTViewComponentView.mm:968`, + clause `|| clipsToBounds`) : aucune sous-couche n'est ajoutée. L'incertitude + de l'étape 0 est levée, et **l'Option B suffit**. +2. **La vue bordée n'a pas besoin d'être la racine de la capture** — + `nested-border` casse aussi. L'hypothèse « c'est un effet de racine » est + fausse. +3. **⚠️ Le cas exact de l'issue ne reproduit pas.** ScrollView + + `snapshotContentContainer` + items bordés rend identiquement dans les deux + modes, avec ou sans fix. Ce qui est reproduit ici a la signature de #677 + (vue bordée + `renderInContext` → bloc uni) sans être prouvé être la même + instance. + +Ce qui distingue `nested-border` (casse) de `scroll-border` (passe) n'est pas +établi. Piste non vérifiée : `snapshotContentContainer` redimensionne +`scrollView.frame`, ce qui force une passe de layout — laquelle pourrait +remonter les calques de contenu après le calque de fond, et rendre l'ordre +correct par accident. Si c'est ça, le bug dépend de l'ordre de montage et est +donc intermittent, ce qui expliquerait qu'il touche certains items du +rapporteur et pas d'autres. + +**Conséquence pour l'étape 6 :** on ne peut pas annoncer au rapporteur que +#677 est corrigé. On peut dire qu'un bug réel de la même famille est corrigé, +et lui demander de vérifier sur son app. + +--- + ## 4. Corriger ⚠️ **Ne rien écrire ici avant que l'étape 3 soit rouge pour la bonne raison.** @@ -250,7 +291,10 @@ transforment un rapport flou en gap **connu, reproductible et surveillé**. > Cf. le précédent `StyleFilters` (#578) : écran avec `status: 'bug'`, pas de fix. > Une note courte suffit — pas de doc de troubleshooting multi-paragraphes. -**Recommandation :** viser **B**, avec A en repli si B ne suffit pas. Décider après l'étape 3. +**Décidé après l'étape 3 : Option B.** Implémentée dans `ios/RNViewShot.mm` +(`RNViewShotSortSublayersByZPosition` + restauration sous `CATransaction` avec +actions désactivées). Les 5 cartes passent avec le fix, dont `border` et +`nested-border` qui échouaient. --- diff --git a/example/e2e/tests/render-in-context.test.js b/example/e2e/tests/render-in-context.test.js index 70d6f1a3..ed2678a0 100644 --- a/example/e2e/tests/render-in-context.test.js +++ b/example/e2e/tests/render-in-context.test.js @@ -1,14 +1,21 @@ /** * #677 — `useRenderInContext` drops the content of bordered views (iOS/Fabric). * - * ⚠️ THIS TEST IS EXPECTED TO FAIL until the fix lands. It is the TDD - * reproduction: it asserts the behaviour we want, against a library that - * currently does not provide it. See PLAN-677.md, step 3. + * 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. * - * What it does: captures the same three 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 @@ -27,7 +34,13 @@ const { } = require('../helpers/pixels'); const MODES = ['draw', 'ric']; -const CARDS = ['plain', 'border', 'border-clipped']; +const CARDS = [ + 'plain', + 'border', + 'border-clipped', + 'nested-border', + 'scroll-border', +]; // Filled by beforeAll, keyed `${mode}-${card}`. const uris = {}; @@ -52,7 +65,7 @@ describe('ViewShot - useRenderInContext (#677)', () => { 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}-border-clipped`))) + await waitFor(element(by.id(`ric-uri-${mode}-scroll-border`))) .toExist() .withTimeout(30000); } @@ -201,4 +214,36 @@ describe('ViewShot - useRenderInContext (#677)', () => { 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. + */ + it('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. + */ + it("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/src/screens/RenderInContextTestScreen.tsx b/example/src/screens/RenderInContextTestScreen.tsx index 7291ef29..d361a432 100644 --- a/example/src/screens/RenderInContextTestScreen.tsx +++ b/example/src/screens/RenderInContextTestScreen.tsx @@ -26,17 +26,21 @@ import { captureRef } from 'react-native-view-shot'; * `renderInContext:` does not. Appended last, it is therefore painted last — * over the text — and the card comes back as a flat white block. * - * The three cards below carry identical content and differ only in the style - * that decides which rendering path RN takes: + * The cards below carry identical content and differ only in what decides + * which rendering path RN takes. Measured before the fix: * - * - `plain` no border → CoreAnimation path, expected fine - * - `border` borderWidth: 1 → appended sublayers, expected broken - * - `borderClipped` border + overflow → mask path (`createMaskLayer`), which - * `renderInContext:` also ignores + * - `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 third card exists to tell those two failure modes apart: the reporter - * never showed whether their container clipped, and the two branches need - * different fixes. + * 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; @@ -53,9 +57,24 @@ const MODES: { key: ModeKey; label: string; useRenderInContext: boolean }[] = [ { key: 'ric', label: 'renderInContext', useRenderInContext: true }, ]; -type CardKey = 'plain' | 'border' | 'border-clipped'; +type CardKey = + | 'plain' + | 'border' + | 'border-clipped' + | 'nested-border' + | 'scroll-border'; -const CARDS: { key: CardKey; label: string; style: ViewStyle }[] = [ +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)', @@ -63,7 +82,7 @@ const CARDS: { key: CardKey; label: string; style: ViewStyle }[] = [ }, { key: 'border', - label: "borderWidth: 1 — the reporter's case", + label: 'borderWidth: 1 — minimal repro', style: { borderWidth: 1, borderColor: '#F6F6F6' }, }, { @@ -71,8 +90,29 @@ const CARDS: { key: CardKey; label: string; style: ViewStyle }[] = [ 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 @@ -90,7 +130,7 @@ type Results = Partial>; type Errors = Partial>; const RenderInContextTestScreen: React.FC = () => { - const refs = useRef>>({}); + const refs = useRef>>({}); const [results, setResults] = useState({}); const [errors, setErrors] = useState({}); const [capturing, setCapturing] = useState(null); @@ -111,6 +151,8 @@ const RenderInContextTestScreen: React.FC = () => { 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 })); @@ -151,16 +193,49 @@ const RenderInContextTestScreen: React.FC = () => { {CARDS.map(card => ( {card.label} - { - refs.current[card.key] = node; - }} - collapsable={false} - style={[styles.card, card.style]} - > - - + {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]} + > + + + )} ))} @@ -256,6 +331,27 @@ const styles = StyleSheet.create({ 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', diff --git a/ios/RNViewShot.mm b/ios/RNViewShot.mm index 820b7f33..78ac2923 100644 --- a/ios/RNViewShot.mm +++ b/ios/RNViewShot.mm @@ -17,6 +17,57 @@ #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); + } +} + +static void RNViewShotRestoreSublayers(NSArray *mutated, + NSArray *> *originals) +{ + for (NSUInteger i = 0; i < mutated.count; i++) { + mutated[i].sublayers = originals[i]; + } +} + @implementation RNViewShot RCT_EXPORT_MODULE() @@ -155,7 +206,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 { From 89c6f723d6e969ee043e525a85b5079040187513 Mon Sep 17 00:00:00 2001 From: gre Date: Sat, 5 Sep 2026 13:35:03 +0200 Subject: [PATCH 3/3] fix(ios): address review of the #677 zPosition fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- PLAN-677.md | 367 -------------------- example/e2e/helpers/pixels.js | 8 +- example/e2e/tests/render-in-context.test.js | 67 ++-- example/package-lock.json | 1 + example/package.json | 1 + example/src/screens/HomeScreen.tsx | 4 +- ios/RNViewShot.mm | 26 +- 7 files changed, 78 insertions(+), 396 deletions(-) delete mode 100644 PLAN-677.md diff --git a/PLAN-677.md b/PLAN-677.md deleted file mode 100644 index bf8e979b..00000000 --- a/PLAN-677.md +++ /dev/null @@ -1,367 +0,0 @@ -# Plan — Issue #677 : contenu blanc sous `useRenderInContext` + `borderWidth` (iOS/Fabric) - -> Approche TDD : on écrit d'abord un test qui **échoue** en reproduisant le bug, -> puis on corrige. Aucune étape de fix n'est entamée avant que l'étape 3 soit rouge -> pour la bonne raison. - ---- - -## 0. État des lieux (vérifié, 2026-09-05) - -### Ce qu'on sait de source sûre - -| Fait | Preuve | -| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| Aucune PR ouverte ne corrige ça | Sur les 20 PRs, seule #683 touche `ios/RNViewShot.mm`, et uniquement `releaseCapture`. | -| `useRenderInContext` n'est **jamais** exercé | 4 occurrences dans tout le repo : `README.md:150`, `CLAUDE.md:54`, `ios/RNViewShot.mm:73`, `src/index.tsx:75`. Zéro écran, zéro test E2E. | -| `ScrollViewTestScreen` ne couvre pas le cas | Il utilise `snapshotContentContainer: true` mais **sans** `useRenderInContext`, et ses `borderWidth: 1` sont sur le wrapper externe (`captureArea`) et `previewImage`, **pas sur les items dans le contenu scrollé** (`colorItem` n'a aucune bordure). | -| iOS ne supporte pas `format: 'raw'` | `src/index.tsx:112` — `raw` est concaténé seulement si `Platform.OS === "android"`. Donc **pas d'accès aux pixels depuis le JS sur iOS**. | -| Le comparateur E2E actuel est inutilisable ici | `example/e2e/helpers/snapshot-matcher.js` compare la **taille en octets** de PNG avec 5 % de tolérance, sur un `device.takeScreenshot()` (écran, pas la capture). Ne peut pas détecter ce bug. | - -### Mécanisme (lu dans `example/node_modules/react-native/React/Fabric/Mounting/ComponentViews/View/RCTViewComponentView.mm`, RN 0.84.1) - -1. **`:968`** — `useCoreAnimationBorderRendering` est vrai seulement si la bordure est uniforme **et** (`borderWidths.left == 0` **ou** `clipsToBounds` **ou** bordure transparente). - - Le style du rapporteur (`borderWidth: 1`, `borderColor: '#F6F6F6'` opaque, **pas de `overflow: hidden`**) fait basculer ce booléen à **`false`**. - -2. **`:994`** — dans ce cas RN abandonne `layer.backgroundColor` et **ajoute des sous-couches** : - - ```objc - layer.backgroundColor = nil; - _backgroundColorLayer = [CALayer layer]; - _backgroundColorLayer.zPosition = BACKGROUND_COLOR_ZPOSITION; // = -1024.0f (:37) - [layer addSublayer:_backgroundColorLayer]; - ``` - - Idem pour `_borderLayer` (`:1016`, zPosition `-1023`). - -3. RN compte sur `zPosition = -1024` pour que ces calques restent **derrière** les sous-vues. Le compositeur Core Animation respecte `zPosition` → **correct à l'écran**. - -4. **`CALayer renderInContext:` ignore `zPosition`** et dessine les sous-couches dans l'ordre du tableau `sublayers`. Le calque de fond, ajouté en dernier par `addSublayer:`, est donc **peint par-dessus le texte**. - - → _« The missing area is rendered as an empty white block »_, et la couleur correspond : leur `content` est `backgroundColor: '#FFFFFF'`. - -5. Leur workaround (bordure déportée sur un wrapper en `backgroundColor` + `padding`) garde toutes les vues en mode CoreAnimation → aucune sous-couche ajoutée → ça marche. - -**Corollaires importants pour le plan :** - -- `snapshotContentContainer` et le ScrollView ne sont **pas** nécessaires au bug. Ce sont des circonstances, pas des causes. Le repro minimal est **une View avec bordure + du texte, capturée avec `useRenderInContext: true`**. C'est ce qu'on teste. -- Le bug est **indépendant du fait que ce soit du texte** : n'importe quel enfant est masqué. -- `drawViewHierarchyInRect` (le défaut) passe par le render server et respecte `zPosition` → non affecté. Le test doit donc capturer **les deux** et les comparer. - -### Incertitude assumée - -Le rapporteur n'a pas montré `overflow: hidden`. S'il l'avait, on tomberait sur une autre branche -(`:1253`, masque `CAShapeLayer`, dont `renderInContext:` ne gère pas non plus le rendu) qui produit -un symptôme voisin par un chemin différent. **L'étape 3 tranche** : on teste les deux variantes. - -### Partie « shadow lente » de l'issue - -`snapshotContentContainer` redimensionne `scrollView.frame` à la taille totale du contenu -(`ios/RNViewShot.mm`, bloc « Save scroll & frame »), ce qui force un layout synchrone de tous les -items ; chacun régénère son image de box-shadow (boucle `_boxShadowLayers`, `RCTViewComponentView.mm:1200`). -C'est du O(N) côté RN, **pas** côté view-shot. - -→ **Hors scope de ce plan.** À traiter comme une note dans la réponse à l'issue, pas comme un fix. - ---- - -## 1. Ajouter l'outillage de comparaison de pixels - -Le blocage principal : il n'existe aujourd'hui aucun moyen d'asserter sur le **contenu d'une capture**. - -```bash -cd example -npm i -D pngjs pixelmatch -``` - -Créer `example/e2e/helpers/pixels.js` : - -- `readPng(filePath)` → `{ width, height, data }` via `pngjs`. -- `regionStats(png, {x, y, w, h})` → `{ meanR, meanG, meanB, uniqueColors, nonWhiteRatio }`. -- `diffRatio(pngA, pngB)` → via `pixelmatch`, ratio de pixels différents (0..1). - -> Note : ce helper est **complémentaire**, pas un remplacement de `snapshot-matcher.js`. -> On ne touche pas à l'existant dans ce plan — les 9 snapshots de référence par plateforme -> restent valides. (Refondre `compareImages` en vraie comparaison pixel est un chantier -> séparé qui mérite sa propre PR.) - -**Comment le test accède au fichier :** avec `result: 'tmpfile'`, `captureRef` renvoie un chemin -dans le tmp du simulateur, qui est **directement lisible depuis le host macOS**. L'écran affiche -l'URI dans un ``, et Detox le récupère via `getAttributes()` -(`.text`). Pattern non encore utilisé dans ce repo — à valider tôt (voir étape 3, garde-fou). - ---- - -## 2. Écrire l'écran de repro - -**Nouveau fichier :** `example/src/screens/RenderInContextTestScreen.tsx` - -Ne pas greffer ça sur `ScrollViewTestScreen` : le sujet est `useRenderInContext`, pas le scroll. -Un écran dédié documente une option publique aujourd'hui totalement non testée. - -### Contenu - -Trois cartes, **volontairement identiques en contenu**, ne différant que par le style qui -déclenche (ou non) le basculement `useCoreAnimationBorderRendering` : - -| testID | Style | Mode RN attendu | -| ------------------------- | -------------------------------------------- | ---------------------------------------- | -| `ric-card-plain` | `backgroundColor: '#FFFFFF'`, aucune bordure | CoreAnimation → OK | -| `ric-card-border` | `+ borderWidth: 1, borderColor: '#F6F6F6'` | **couches ajoutées → bug** | -| `ric-card-border-clipped` | `+ borderWidth: 1 + overflow: 'hidden'` | branche masque — départage l'incertitude | - -Chaque carte contient du **texte noir sur fond blanc**, bien contrasté et couvrant une large -part de la surface (c'est ce qui rend la disparition mesurable). Taille fixe et connue, -p.ex. 300×120, pour que les régions testées soient déterministes. - -### Contrôles - -- Bouton `ric-capture-drawhierarchy` → capture les 3 cartes avec `useRenderInContext: false` -- Bouton `ric-capture-renderincontext` → idem avec `useRenderInContext: true` -- Options communes : `{ format: 'png', quality: 1, result: 'tmpfile' }` -- Chaque résultat expose : - - `{uri}` - - un `` pour l'inspection visuelle manuelle - -### Enregistrement - -- `example/App.tsx` : ajouter `RenderInContext: undefined` à `RootStackParamList`, l'import, - et le ``. -- `example/src/screens/HomeScreen.tsx` : entrée dans la catégorie `🔴 RENDERING CORRECTNESS` - (à côté de `StyleFilters`), avec `status: 'bug'` : - - ```ts - { - key: 'RenderInContext', - title: 'RenderInContext', - description: 'useRenderInContext + borderWidth — content disappears (#677)', - emoji: '🩹', - priority: 'high', - status: 'bug', - } - ``` - - → le testID de navigation devient `nav-renderincontext` - (pattern `nav-${title.toLowerCase().replace(/\s+/g,'-')}`, `HomeScreen.tsx:183`). - ---- - -## 3. Écrire le test qui échoue ⛔ **← le cœur du TDD** - -**Nouveau fichier :** `example/e2e/tests/render-in-context.test.js` - -Structure calquée sur `snapshot-content-container.test.js` (helper `goBackToHome`, navigation -par testID avec fallback texte, `launchArgs: { detoxEnableSynchronization: 0 }`). - -### Assertions, de la plus robuste à la plus fine - -L'assertion **ne doit pas** dépendre d'un PNG de référence : c'est une propriété invariante, -pas un pixel-perfect. On compare les deux modes entre eux. - -``` -Pour chaque carte C : - uriA = capture(C, useRenderInContext: false) // référence connue-bonne - uriB = capture(C, useRenderInContext: true) - - 1. ASSERTION PRINCIPALE (indépendante de la plateforme) - regionStats(B, centre).uniqueColors > 1 - → « la carte capturée n'est pas un aplat uni » - C'est exactement le symptôme rapporté : bloc blanc vide. - - 2. ASSERTION DE NON-RÉGRESSION CROISÉE - diffRatio(A, B) < 0.02 - → les deux stratégies doivent produire le même rendu pour du contenu statique - - 3. GARDE-FOU (doit passer AVANT et APRÈS le fix) - Sur `ric-card-plain` : les deux modes matchent déjà. - Si celui-ci échoue, c'est l'outillage qui est cassé, pas la lib. -``` - -### Résultat attendu à cette étape - -``` -✅ ric-card-plain — les deux modes concordent -❌ ric-card-border — mode renderInContext = aplat uni ← LE BUG -? ric-card-border-clipped — départage la branche masque -``` - -**Ne pas passer à l'étape 4 tant que :** - -- le garde-fou (3) ne passe pas — sinon on chasse un fantôme d'outillage ; -- `ric-card-border` n'échoue pas — sinon on n'a pas reproduit #677. - -Vérifier aussi visuellement le `ric-preview-*` correspondant : le bloc blanc doit être -**visible à l'œil** dans l'app. Si le test est rouge mais que l'aperçu est correct, c'est -l'assertion qui est mauvaise. - -### Si ça ne reproduit pas - -Ne pas forcer l'assertion. Explorer dans cet ordre : - -1. Vérifier que le simulateur tourne bien en **Fabric** (le bug est spécifique au nouveau - moteur — Paper dessinait les bordures dans `layer.contents`, sans sous-couche ajoutée). -2. Ajouter une variante avec `borderRadius` non uniforme (autre déclencheur de la même branche). -3. Ajouter une variante avec `boxShadow` (crée aussi des sous-couches, `:1200`). -4. En dernier recours, dumper l'arbre de calques avec un `RCT_EXPORT_METHOD` de debug - temporaire pour confirmer la présence de `_backgroundColorLayer`. - ---- - -## 3bis. Résultats mesurés (2026-09-05, iPhone 17 Pro, RN 0.84.1) - -Étape 3 exécutée. Mesures au pixel, avant fix : - -| carte | uniqueColors (ric) | diff draw↔ric | verdict | -| ---------------- | ------------------ | -------------- | ------------------------------------------- | -| `plain` | 16 | 0.0000 | ✓ garde-fou | -| `border` | **1** | **0.1092** | ✕ **bug reproduit** | -| `border-clipped` | 16 | 0.0000 | ✓ non affectée | -| `nested-border` | — | — | ✕ casse aussi | -| `scroll-border` | 16 | < 0.02 | ✓ **le cas du rapporteur NE reproduit PAS** | - -Trois conclusions : - -1. **La branche masque est hors de cause.** `overflow: hidden` rebascule - `useCoreAnimationBorderRendering` à `true` (`RCTViewComponentView.mm:968`, - clause `|| clipsToBounds`) : aucune sous-couche n'est ajoutée. L'incertitude - de l'étape 0 est levée, et **l'Option B suffit**. -2. **La vue bordée n'a pas besoin d'être la racine de la capture** — - `nested-border` casse aussi. L'hypothèse « c'est un effet de racine » est - fausse. -3. **⚠️ Le cas exact de l'issue ne reproduit pas.** ScrollView + - `snapshotContentContainer` + items bordés rend identiquement dans les deux - modes, avec ou sans fix. Ce qui est reproduit ici a la signature de #677 - (vue bordée + `renderInContext` → bloc uni) sans être prouvé être la même - instance. - -Ce qui distingue `nested-border` (casse) de `scroll-border` (passe) n'est pas -établi. Piste non vérifiée : `snapshotContentContainer` redimensionne -`scrollView.frame`, ce qui force une passe de layout — laquelle pourrait -remonter les calques de contenu après le calque de fond, et rendre l'ordre -correct par accident. Si c'est ça, le bug dépend de l'ordre de montage et est -donc intermittent, ce qui expliquerait qu'il touche certains items du -rapporteur et pas d'autres. - -**Conséquence pour l'étape 6 :** on ne peut pas annoncer au rapporteur que -#677 est corrigé. On peut dire qu'un bug réel de la même famille est corrigé, -et lui demander de vérifier sur son app. - ---- - -## 4. Corriger - -⚠️ **Ne rien écrire ici avant que l'étape 3 soit rouge pour la bonne raison.** -Le choix ci-dessous dépend de ce que l'étape 3 révèle, notamment du sort de -`ric-card-border-clipped`. - -### Option A — rendu manuel trié par `zPosition` (le vrai fix) - -Dans `ios/RNViewShot.mm`, remplacer l'appel unique - -```objc -[rendered.layer renderInContext:rendererContext.CGContext]; -``` - -par une descente récursive qui, à chaque niveau, trie `layer.sublayers` par `zPosition` -(tri **stable**, pour préserver l'ordre du tableau à zPosition égale — c'est la sémantique -de Core Animation) avant de rendre chaque sous-couche dans son propre espace de coordonnées. - -- ✅ Corrige la classe entière de bugs, pas juste `borderWidth` : shadows, backgrounds - non uniformes, tout ce qui repose sur `zPosition`. -- ❌ Réimplémente une partie du compositeur : `mask`, `masksToBounds`, `transform`, - `shouldRasterize`, `opacity` composé. Risque réel de régression sur des cas aujourd'hui OK. -- ❌ Ne corrige **pas** la branche masque (`:1253`) — `renderInContext:` ignore aussi `mask`. - -### Option B — gérer uniquement `zPosition`, garder `renderInContext` sinon - -Trier les sous-couches par `zPosition` **sans** réimplémenter le rendu : réordonner -temporairement le tableau `sublayers` autour de l'appel, puis restaurer. - -- ✅ Beaucoup plus petit, et cible précisément le mécanisme identifié. -- ❌ Mute l'arbre de calques pendant la capture — à faire strictement sur le thread UI et à - restaurer dans tous les cas (y compris exception). Peut provoquer un flash visible. -- ❌ Ne couvre toujours pas la branche masque. - -### Option C — documenter, ne pas corriger - -Si A et B s'avèrent trop risquées, l'écran + le test restent le livrable de valeur : ils -transforment un rapport flou en gap **connu, reproductible et surveillé**. - -> Cf. le précédent `StyleFilters` (#578) : écran avec `status: 'bug'`, pas de fix. -> Une note courte suffit — pas de doc de troubleshooting multi-paragraphes. - -**Décidé après l'étape 3 : Option B.** Implémentée dans `ios/RNViewShot.mm` -(`RNViewShotSortSublayersByZPosition` + restauration sous `CATransaction` avec -actions désactivées). Les 5 cartes passent avec le fix, dont `border` et -`nested-border` qui échouaient. - ---- - -## 5. Marquer le test comme attendu-vert - -Une fois le fix en place : - -- Retirer tout `.failing` / `.skip` posé à l'étape 3. -- Faire tourner **l'ensemble** de la suite iOS, pas seulement le nouveau test — l'Option A - ou B touche le chemin de rendu partagé, et les 9 snapshots de référence iOS existants - sont le filet de sécurité. -- Basculer l'entrée HomeScreen de `status: 'bug'` à `status: 'tested'`. - -```bash -cd example -npm run build:e2e:ios -npm run test:e2e:ios -``` - -⚠️ **Ne pas** lancer `UPDATE_SNAPSHOTS=true` par réflexe si des références bougent : -sur ce fix précis, une référence qui change est soit une vraie correction (à valider à l'œil, -image avant/après), soit une régression. Regarder avant d'écraser. - ---- - -## 6. Répondre à l'issue - -**À faire seulement après validation, et à me faire relire avant post** (aucune action publique -sans feu vert explicite). - -Contenu utile, court : - -- Cause confirmée, avec le pointeur exact : `RCTViewComponentView.mm:968` et `:994`, - `BACKGROUND_COLOR_ZPOSITION = -1024`, et le fait que `renderInContext:` ignore `zPosition`. -- Sa demande de repro minimal devient inutile : on a le mécanisme et un écran dédié. -- Confirmer que son workaround est correct **et expliquer pourquoi** (il garde les vues en - mode CoreAnimation) — c'est ce qui lui permettra de généraliser à d'autres styles. -- La lenteur des shadows est distincte : layout O(N) déclenché par le resize de frame de - `snapshotContentContainer`, côté RN. Le mentionner, ne pas le traiter ici. - ---- - -## Ordre d'exécution et interaction avec les PRs - -Ce chantier touche `ios/RNViewShot.mm` (chemin de **capture**), `example/App.tsx`, -`example/src/screens/HomeScreen.tsx`, plus des fichiers neufs. - -- **#683** touche `ios/RNViewShot.mm` mais uniquement `releaseCapture` → zones disjointes, - pas de conflit réel, mais git le signalera peut-être. Merger #683 **avant** d'attaquer - l'étape 4 évite d'avoir à arbitrer. -- **#685** et **#692** touchent `example/src/screens/` → aucun chevauchement de fichier - avec les nôtres. -- Aucune PR ne touche `App.tsx` ni `HomeScreen.tsx` → l'étape 2 est sûre dès maintenant. - -**Les étapes 1 à 3 sont indépendantes de tout le backlog de PRs et peuvent démarrer -immédiatement.** - ---- - -## Récapitulatif des fichiers - -| Fichier | Action | -| --------------------------------------------------- | ---------------------------------------------- | -| `example/package.json` | + `pngjs`, `pixelmatch` en devDeps | -| `example/e2e/helpers/pixels.js` | **créer** | -| `example/src/screens/RenderInContextTestScreen.tsx` | **créer** | -| `example/App.tsx` | + route `RenderInContext` | -| `example/src/screens/HomeScreen.tsx` | + entrée dans `🔴 RENDERING CORRECTNESS` | -| `example/e2e/tests/render-in-context.test.js` | **créer** — doit être ROUGE d'abord | -| `ios/RNViewShot.mm` | fix (étape 4) — **pas avant que 3 soit rouge** | diff --git a/example/e2e/helpers/pixels.js b/example/e2e/helpers/pixels.js index 2646e0f5..457d9e34 100644 --- a/example/e2e/helpers/pixels.js +++ b/example/e2e/helpers/pixels.js @@ -40,8 +40,12 @@ function readPng(filePath) { * logical terms without worrying about the device scale factor. */ function clampRegion(png, region) { - const x = Math.max(0, Math.floor(region.x)); - const y = Math.max(0, Math.floor(region.y)); + // 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 }; diff --git a/example/e2e/tests/render-in-context.test.js b/example/e2e/tests/render-in-context.test.js index ed2678a0..5fcd9d96 100644 --- a/example/e2e/tests/render-in-context.test.js +++ b/example/e2e/tests/render-in-context.test.js @@ -24,7 +24,8 @@ */ // `expect` in this scope is Detox's element matcher. Value assertions need -// Jest's, which Detox's docs tell you to require explicitly. +// 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, @@ -46,7 +47,16 @@ const CARDS = [ 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' }, @@ -167,14 +177,17 @@ describe('ViewShot - useRenderInContext (#677)', () => { * If this fails, the tooling is broken (bad URI, unreadable PNG, wrong * region) and every other result in this file is meaningless. */ - it('renders the border-less card identically through both strategies', () => { - const draw = readPng(uris['draw-plain']); - const ric = readPng(uris['ric-plain']); + 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); - }); + const stats = regionStats(ric, centerRegion(ric)); + jestExpect(stats.uniqueColors).toBeGreaterThan(1); + jestExpect(diffRatio(draw, ric)).toBeLessThan(0.02); + }, + ); /** * THE BUG (#677). @@ -184,7 +197,7 @@ describe('ViewShot - useRenderInContext (#677)', () => { * `_backgroundColorLayer` held behind the content only by `zPosition`. * `renderInContext:` ignores `zPosition` and paints it last — over the text. */ - it('renders the bordered card identically through both strategies', () => { + itIOS('renders the bordered card identically through both strategies', () => { const draw = readPng(uris['draw-border']); const ric = readPng(uris['ric-border']); @@ -206,21 +219,24 @@ describe('ViewShot - useRenderInContext (#677)', () => { * fixing that needs a different change than the zPosition ordering, so which * of these two tests fails decides the shape of the fix. */ - it('renders the clipped bordered card identically through both strategies', () => { - const draw = readPng(uris['draw-border-clipped']); - const ric = readPng(uris['ric-border-clipped']); + 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); - }); + 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. */ - it('renders a bordered CHILD of the capture root identically', () => { + itIOS('renders a bordered CHILD of the capture root identically', () => { const draw = readPng(uris['draw-nested-border']); const ric = readPng(uris['ric-nested-border']); @@ -238,12 +254,15 @@ describe('ViewShot - useRenderInContext (#677)', () => { * the reported configuration itself is covered, rather than only our * reduction of it. */ - it("renders the reporter's ScrollView case identically through both strategies", () => { - const draw = readPng(uris['draw-scroll-border']); - const ric = readPng(uris['ric-scroll-border']); + 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); - }); + 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 fb2f8053..86831d6f 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -45,6 +45,7 @@ "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", diff --git a/example/package.json b/example/package.json index 53c993f8..1b512440 100644 --- a/example/package.json +++ b/example/package.json @@ -54,6 +54,7 @@ "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", diff --git a/example/src/screens/HomeScreen.tsx b/example/src/screens/HomeScreen.tsx index d1052f7e..7b39016b 100644 --- a/example/src/screens/HomeScreen.tsx +++ b/example/src/screens/HomeScreen.tsx @@ -119,10 +119,10 @@ const testCases: { [category: string]: TestCase[] } = { key: 'RenderInContext', title: 'RenderInContext', description: - 'useRenderInContext + borderWidth — content disappears (#677)', + 'useRenderInContext vs drawViewHierarchyInRect — zPosition (#677)', emoji: '🩹', priority: 'high', - status: 'bug', + status: 'tested', }, ], '🟠 ADVANCED TESTS': [ diff --git a/ios/RNViewShot.mm b/ios/RNViewShot.mm index 78ac2923..43860e13 100644 --- a/ios/RNViewShot.mm +++ b/ios/RNViewShot.mm @@ -60,11 +60,35 @@ static void RNViewShotSortSublayersByZPosition(CALayer *layer, } } +/** + * 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++) { - mutated[i].sublayers = originals[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; } }