diff --git a/examples/bare/App.tsx b/examples/bare/App.tsx index 0f73fe32..90e23218 100644 --- a/examples/bare/App.tsx +++ b/examples/bare/App.tsx @@ -67,6 +67,11 @@ import { } from './scenarios/data'; import { isNetworkScenario, runNetworkScenario } from './scenarios/network'; import { SecureProbe, isSecureScenario } from './scenarios/secure'; +import { + SecureVisibleStage, + type SecureVisibleScenario, + isSecureVisibleScenario, +} from './scenarios/secure-visible'; import { isAttributeScenario, runAttributeScenario, @@ -308,6 +313,10 @@ export default function App() { const [flowStage, setFlowStage] = useState(); /** Set by a campaign API scenario that puts something on screen (scenarios/api.tsx). */ const [apiStage, setApiStage] = useState(); + /** Set once Launched for scenarios/secure-visible.tsx (#86). */ + const [secureVisible, setSecureVisible] = useState< + { scenario: SecureVisibleScenario; nonce: string } | undefined + >(); useEffect(() => { let cancelled = false; @@ -563,6 +572,13 @@ export default function App() { return; } + if (isSecureVisibleScenario(choice.scenario)) { + if (!cancelled) { + setSecureVisible({ scenario: choice.scenario, nonce: choice.nonce }); + } + return; + } + if (isReportingScenario(choice.scenario)) { runReportingScenario(choice.scenario, choice.nonce); return; @@ -772,6 +788,9 @@ export default function App() { )} {flowStage !== undefined && } {apiStage !== undefined && } + {secureVisible !== undefined && ( + + )} ); } diff --git a/examples/bare/e2e/secure-perf.test.ts b/examples/bare/e2e/secure-perf.test.ts new file mode 100644 index 00000000..fbebda86 --- /dev/null +++ b/examples/bare/e2e/secure-perf.test.ts @@ -0,0 +1,168 @@ +/** + * bugsee/bugsee-react-native#86: what ``'s clipping costs. + * + * Scenario `secure-perf` (scenarios/secure-visible.tsx): 20 `` + * in four horizontal ScrollViews, under two `overflow: 'hidden'` views, in a + * vertical ScrollView, so every view has four clipping ancestors. Every tick + * of the shared re-measure loop, which measures all 20 views and their + * ancestors and publishes, is timed on the JS thread with `performance.now()` + * for 40 s with nothing moving and all 20 views on screen (`static`: the + * re-measuring alone, and the most clipping work), then for 40 s with every + * ScrollView scrolled every 400 ms (`scrolling`: rectangles keep changing, so + * they also cross the bridge, and views go partly and fully out of sight). + * + * The hook is the app's own (a `setInterval` wrapper around the 20 views' + * mount), so the same test times any version of the package: the baseline is + * this test against origin/main's `src/secure`. `E2E_PERF_LABEL` names the + * build in the report line (e.g. `new`, `main`). + */ +import { + TARGET_NAME, + describeDevice, + escape, + must, + report, + startDeviceLog, + startRun, + stopApp, + stopDeviceLog, +} from './harness'; +import { type DeviceLog, resetScenario } from './scenario'; + +jest.setTimeout(6 * 60_000); + +/** The budget for one tick of the loop, all 20 views included. */ +const P95_BUDGET_MS = 2; +const MIN_TICKS = 300; + +/** + * Each of `static`, `scrolling` and `clipped` also reports `-frames`: + * every frame callback of the per-frame re-measure that clipped views go on + * (their ticks skip them). `clipped` has every view partly clipped and + * moving, so all 20 are on it: the worst case. A frame mode's "ticks" are + * frames. + * + * `watch` times, every frame for 20 s, the full hidden check (`isHidden`) for + * all 20 views: an upper bound of what they cost if every one were withdrawn + * at once, since a withdrawn view's frame callback runs only its screens part + * (`screensHide`, cached per screen in a pass). Its "ticks" are frames. + */ +const MODES = [ + 'static', + 'static-frames', + 'scrolling', + 'scrolling-frames', + 'clipped', + 'clipped-frames', + 'watch', +] as const; + +/** Frame modes run only while some view is clipped: only `clipped-frames` must have many. */ +const minTicks = (mode: string): number => (mode.endsWith('-frames') && mode !== 'clipped-frames' ? 0 : MIN_TICKS); + +describeDevice(` re-measure cost on ${TARGET_NAME}`, () => { + let log: DeviceLog; + const results = new Map>(); + + beforeAll(async () => { + log = await startDeviceLog('#86 perf', '#86 perf'); + const run = await startRun('secure-perf'); + const nonce = run.scenario.nonce; + for (const mode of MODES) { + const done = new RegExp(`BUGSEE_E2E secure-perf (result mode=${mode}|threw) .*nonce=${escape(nonce)}`); + const line = must(await log.waitFor(done, 240_000, run.start), `the secure-perf ${mode} result`, run.start); + const result = Object.fromEntries( + [...line.text.matchAll(/(\w+)=(\S+)/g)].map(([, key, value]) => [key!, value!]), + ); + results.set(mode, result); + report('secure-perf', { label: process.env.E2E_PERF_LABEL ?? 'unlabelled', dev: run.dev, ...result }); + } + }); + + afterAll(async () => { + try { + await stopApp(); + } finally { + stopDeviceLog(log); + resetScenario(); + } + }); + + describe.each(MODES)('%s', (mode) => { + it('timed the one shared loop over enough ticks', () => { + const result = results.get(mode)!; + expect(result.intervals).toBe('1'); + expect(result.secures).toBe('20'); + expect(Number(result.ticks)).toBeGreaterThanOrEqual(minTicks(mode)); + }); + + it(`keeps p95 of a tick under ${P95_BUDGET_MS} ms`, () => { + const result = results.get(mode)!; + if (result.ticks === '0') { + return; + } + expect(Number(result.p95)).toBeLessThan(P95_BUDGET_MS); + }); + }); +}); + +/** + * Scenario `secure-perf-many`: 200 `` in four still + * ScrollViews, every one partly clipped or wholly out of sight. Once they + * have settled, a frame costs only the four watched ScrollView contents + * (`many-frames`), and a tick only its bounded turns of parked views + * (`many`). + */ +describeDevice(` cost of 200 still, clipped views on ${TARGET_NAME}`, () => { + let log: DeviceLog; + const results = new Map>(); + + beforeAll(async () => { + log = await startDeviceLog('#86 perf many', '#86 perf many'); + const run = await startRun('secure-perf-many'); + const nonce = run.scenario.nonce; + for (const mode of ['many', 'many-frames']) { + const done = new RegExp(`BUGSEE_E2E secure-perf (result mode=${mode}|threw) .*nonce=${escape(nonce)}`); + const line = must(await log.waitFor(done, 120_000, run.start), `the secure-perf ${mode} result`, run.start); + const result = Object.fromEntries( + [...line.text.matchAll(/(\w+)=(\S+)/g)].map(([, key, value]) => [key!, value!]), + ); + results.set(mode, result); + report('secure-perf', { label: process.env.E2E_PERF_LABEL ?? 'unlabelled', dev: run.dev, ...result }); + } + }); + + afterAll(async () => { + try { + await stopApp(); + } finally { + stopDeviceLog(log); + resetScenario(); + } + }); + + it('timed every tick and frame of 200 views', () => { + expect(results.get('many')!.secures).toBe('200'); + expect(Number(results.get('many')!.ticks)).toBeGreaterThanOrEqual(MIN_TICKS); + expect(Number(results.get('many-frames')!.ticks)).toBeGreaterThanOrEqual(MIN_TICKS); + }); + + // Every tick re-checks every parked view by measuring it alone: O(n) per + // tick, the same per-view self-measure main makes every tick for every + // view. Release keeps the 2 ms budget. Debug (React's dev build) spends + // about 12 µs per view on it, so 200 views take ~2.5 ms there. Main, on + // the same 200 views on the API 24 emulator, measured p95 153 ms a tick in + // Debug and 70 ms in Release (its registry re-flattened every owner's + // union per owner). The scoped Debug budget for this mode is 4 ms. + const MANY_DEBUG_TICK_BUDGET_MS = 4; + + it('keeps p95 of a tick under 2 ms in Release, 4 ms in Debug', () => { + const result = results.get('many')!; + const budget = result.dev === 'true' ? MANY_DEBUG_TICK_BUDGET_MS : P95_BUDGET_MS; + expect(Number(result.p95)).toBeLessThan(budget); + }); + + it('keeps a frame near nothing: p95 under 0.5 ms', () => { + expect(Number(results.get('many-frames')!.p95)).toBeLessThan(0.5); + }); +}); diff --git a/examples/bare/e2e/secure-visible.test.ts b/examples/bare/e2e/secure-visible.test.ts new file mode 100644 index 00000000..f4d761b5 --- /dev/null +++ b/examples/bare/e2e/secure-visible.test.ts @@ -0,0 +1,210 @@ +/** + * bugsee/bugsee-react-native#86: `` publishes only the part of + * itself that is on screen, and nothing while an ancestor hides it. + * + * Scenario `secure-visible` (scenarios/secure-visible.tsx): one + * `` in a ScrollView, scrolled fully in, partly out (the top + * cut off by the viewport), fully out and back; then two sibling "tabs" with a + * `` each, the inactive one styled `display: 'none'`. After each + * change it holds still and logs the window rectangles, in points, of the + * viewport and of a transparent twin of each secure view on screen. + * + * The ground truth for what was published is native's own log of each + * publish, `BugseeRN secure published display=0 surface=0 raw=[...]`, which + * React Native's side sends only on a change (the registry skips an unchanged + * union). The last one before a phase's marker is therefore what native held + * during the hold. It is logged only while `log.tag.BugseeRN` is DEBUG, which + * this test sets and then clears, so it is Android-only; iOS logs no such + * line. + * + * Expected rectangles are computed here from the scenario's measurements: the + * twin intersected with the viewport, in pixels at the logged ratio, rounded + * outwards as the registry rounds. A one-pixel tolerance absorbs float noise. + */ +import { + ON_ANDROID, + type Run, + TARGET_NAME, + describeDevice, + escape, + must, + report, + startDeviceLog, + startRun, + stopApp, + stopDeviceLog, +} from './harness'; +import { type DeviceLog, type LogLine, adb, resetScenario } from './scenario'; + +jest.setTimeout(4 * 60_000); + +const describeAndroid = ON_ANDROID ? describeDevice : describe.skip; + +const PUBLISHED = /BugseeRN\s*:\s*secure published display=0 surface=0 raw=\[([^\]]*)\]/; + +interface Box { + x: number; + y: number; + w: number; + h: number; +} + +interface Phase { + viewport: Box; + self: Box; + tab: { name: string; rect: Box }; + ratio: number; + /** The rectangles native held, as [left, top, right, bottom] pixels. */ + published: number[][]; + line: LogLine; +} + +function box(text: string): Box { + const [x, y, w, h] = text.split(',').map(Number) as [number, number, number, number]; + return { x, y, w, h }; +} + +/** `a` cut to `b`, or `null` when nothing of it is left. */ +function intersect(a: Box, b: Box): Box | null { + const left = Math.max(a.x, b.x); + const top = Math.max(a.y, b.y); + const right = Math.min(a.x + a.w, b.x + b.w); + const bottom = Math.min(a.y + a.h, b.y + b.h); + return right > left && bottom > top ? { x: left, y: top, w: right - left, h: bottom - top } : null; +} + +/** As the registry flattens a rectangle: scaled, then rounded outwards. */ +function pixels({ x, y, w, h }: Box, ratio: number): number[] { + return [Math.floor(x * ratio), Math.floor(y * ratio), Math.ceil((x + w) * ratio), Math.ceil((y + h) * ratio)]; +} + +function rects(flat: string): number[][] { + const values = flat.trim() === '' ? [] : flat.split(',').map(value => Number(value.trim())); + if (values.length % 4 !== 0) { + throw new Error(`malformed raw buffer: [${flat}]`); + } + const out: number[][] = []; + for (let i = 0; i < values.length; i += 4) { + out.push(values.slice(i, i + 4)); + } + return out; +} + +/** Every expected rectangle matched by one published within a pixel, and nothing else published. */ +function expectPublished(phase: string, published: number[][], expected: number[][]): void { + const left = [...published]; + const unmatched: number[][] = []; + for (const want of expected) { + const index = left.findIndex(got => got.every((value, i) => Math.abs(value - want[i]!) <= 1)); + if (index < 0) { + unmatched.push(want); + } else { + left.splice(index, 1); + } + } + expect({ phase, unmatched, unexpected: left }).toEqual({ phase, unmatched: [], unexpected: [] }); +} + +describeAndroid(` publishes only what is on screen, on ${TARGET_NAME}`, () => { + let log: DeviceLog; + let run: Run; + const phases = new Map(); + + beforeAll(async () => { + log = await startDeviceLog('#86', '#86'); + await adb('shell', 'setprop', 'log.tag.BugseeRN', 'DEBUG'); + run = await startRun('secure-visible'); + const nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + const marker = new RegExp( + 'BUGSEE_E2E secure-visible phase=(\\S+) viewport=(\\S+) self=(\\S+) tab=(A|B):(\\S+) ratio=(\\S+) ' + + `nonce=${escape(nonce)}`, + ); + const finished = new RegExp(`BUGSEE_E2E secure-visible (done|threw).* nonce=${escape(nonce)}`); + must( + await log.waitFor(finished, 60_000, run.start), + 'the secure-visible scenario finishing', + run.start, + ); + for (const line of log.all(marker, run.start)) { + const [, name, viewport, self, tab, tabRect, ratio] = marker.exec(line.text)!; + const publishes = log.all(PUBLISHED, run.start, line.index); + const last = must(publishes.pop(), `a publish before phase ${name}`, run.start); + phases.set(name!, { + viewport: box(viewport!), + self: box(self!), + tab: { name: tab!, rect: box(tabRect!) }, + ratio: Number(ratio), + published: rects(PUBLISHED.exec(last.text)![1]!), + line, + }); + report(`phase ${name}`, { marker: line.text.trim(), published: last.text.trim() }); + } + }); + + afterAll(async () => { + try { + await stopApp(); + } finally { + await adb('shell', 'setprop', 'log.tag.BugseeRN', "''").catch(() => {}); + stopDeviceLog(log); + resetScenario(); + } + }); + + const phase = (name: string): Phase => { + const found = phases.get(name); + if (found === undefined) { + throw new Error(`the scenario logged no phase ${name} (saw ${[...phases.keys()].join(', ')})`); + } + return found; + }; + + /** What should be published in `name`: the scrolled view cut to its viewport, and the active tab's view. */ + function expected(name: string): number[][] { + const p = phase(name); + const visible = intersect(p.self, p.viewport); + return [...(visible === null ? [] : [pixels(visible, p.ratio)]), pixels(p.tab.rect, p.ratio)]; + } + + it('publishes the whole view while it is fully inside the viewport', () => { + const p = phase('visible'); + expect(intersect(p.self, p.viewport)).toEqual(p.self); + expectPublished('visible', p.published, expected('visible')); + }); + + it('publishes only the visible part while partly scrolled out', () => { + const p = phase('partial'); + // Precondition: the viewport really cuts the view. + const visible = intersect(p.self, p.viewport); + expect(visible).not.toBeNull(); + expect(visible!.h).toBeLessThan(p.self.h); + expectPublished('partial', p.published, expected('partial')); + }); + + it('publishes nothing for it while fully scrolled out', () => { + const p = phase('out'); + expect(intersect(p.self, p.viewport)).toBeNull(); + expect(p.published).toHaveLength(1); + expectPublished('out', p.published, expected('out')); + }); + + it('publishes it again once scrolled back', () => { + expectPublished('back', phase('back').published, expected('back')); + }); + + it("withdraws the hidden tab's view and publishes the shown one's", () => { + expect(phase('tab-b').tab.name).toBe('B'); + expectPublished('tab-b', phase('tab-b').published, expected('tab-b')); + // The view on tab A, still mounted under display: 'none', is not there. + const tabA = phase('tab-a').tab.rect; + const ratio = phase('tab-b').ratio; + expect(phase('tab-b').published).not.toContainEqual(pixels(tabA, ratio)); + }); + + it('brings the tab back when it is shown again', () => { + expect(phase('tab-a').tab.name).toBe('A'); + expectPublished('tab-a', phase('tab-a').published, expected('tab-a')); + }); +}); diff --git a/examples/bare/scenarios/secure-visible.tsx b/examples/bare/scenarios/secure-visible.tsx new file mode 100644 index 00000000..8a5b3e11 --- /dev/null +++ b/examples/bare/scenarios/secure-visible.tsx @@ -0,0 +1,476 @@ +/** + * `` publishes only what is on screen (bugsee/bugsee-react-native#86), + * asserted by e2e/secure-visible.test.ts, and what that costs each re-measure, + * asserted by e2e/secure-perf.test.ts. + * + * secure-visible one `` in a ScrollView, scrolled fully in, + * partly out, fully out and back; then two sibling "tabs", + * each holding a ``, the inactive one styled + * `display: 'none'`. After each change it holds still and + * logs `secure-visible phase=` with the window + * rectangles (points) of the ScrollView's viewport and of a + * transparent twin of each secure view on screen. The test + * compares them with the rectangles native says it was + * given (`BugseeRN secure published ... raw=`). + * secure-perf 20 `` in four horizontal ScrollViews, under + * two `overflow: 'hidden'` views, in a vertical ScrollView; + * at rest all 20 are on screen. Every tick of the shared + * re-measure loop is timed with `performance.now()` for 40 s + * with nothing moving, then for 40 s with every ScrollView + * scrolled every 400 ms (moving views partly and fully out + * of sight), then for 40 s with all 20 taller than their + * rows (every one partly clipped, so on the per-frame + * re-measure) and nudged every 400 ms, each mode timing + * both ticks and frame callbacks (`-frames`), then for + * 20 s the full hidden check (`isHidden`, props only) + * for all 20 every frame, an upper bound of what a + * withdrawn view checks each frame, and each mode's + * percentiles logged as `secure-perf result`. + * + * The perf hook is the app's own, not the package's: while the 20 views mount + * (their layout effects start the loop's one interval), `setInterval` is + * wrapped so the interval with the loop's period runs its callback timed. The + * same hook therefore times any version of the package, which is how the + * baseline is taken. + */ +import { useEffect, useLayoutEffect, useRef, useState } from 'react'; +import { PixelRatio, ScrollView, StyleSheet, View } from 'react-native'; +import { BugseeSecure } from '@bugsee/react-native'; +// Deep-imported for secure-perf's `watch` mode only: the full hidden check, +// timed on the same views (as scenarios/channel.ts does). +import { ancestryOfInstance, isHidden } from '@bugsee/react-native/src/secure/ancestry'; + +export const SECURE_VISIBLE_SCENARIOS = ['secure-visible', 'secure-perf', 'secure-perf-many'] as const; + +export type SecureVisibleScenario = (typeof SECURE_VISIBLE_SCENARIOS)[number]; + +export function isSecureVisibleScenario(name: string): name is SecureVisibleScenario { + return (SECURE_VISIBLE_SCENARIOS as readonly string[]).includes(name); +} + +/** The package's `SECURE_REMEASURE_MS`: the period of the interval the perf hook times. */ +const REMEASURE_MS = 100; +/** From a change to the marker: several re-measures, all settled. */ +const SETTLE_MS = 1_200; +/** How long secure-perf times the loop for: 400 ticks at 100 ms, 300 at the least. */ +const PERF_MS = 40_000; +const PERF_WARMUP_MS = 2_000; +/** How long secure-perf's `watch` mode times frames for. */ +const WATCH_MS = 20_000; + +function mark(message: string): void { + console.log(`BUGSEE_E2E ${message}`); +} + +function sleep(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +type Measurable = { measureInWindow(cb: (x: number, y: number, w: number, h: number) => void): void }; + +/** `x,y,w,h` in window points, or `none` when there is nothing to measure. */ +function measured(view: Measurable | null): Promise { + return new Promise(resolve => { + if (view === null) { + resolve('none'); + return; + } + view.measureInWindow((x, y, w, h) => resolve(`${x},${y},${w},${h}`)); + }); +} + +const VIEWPORT = { position: 'absolute', top: 120, left: 0, right: 0, height: 300 } as const; +const SCROLLED = { position: 'absolute', top: 100, left: 40, width: 200, height: 100 } as const; +const TABS = { position: 'absolute', top: 460, left: 0, right: 0, height: 200 } as const; +const TAB_A = { position: 'absolute', top: 20, left: 40, width: 120, height: 60 } as const; +const TAB_B = { position: 'absolute', top: 100, left: 200, width: 120, height: 60 } as const; + +/** Scroll offsets: fully in, top half cut off by the viewport, fully out. */ +const PHASES = [ + ['visible', 0], + ['partial', 150], + ['out', 600], + ['back', 0], +] as const; + +function SecureVisibleProbe({ nonce }: { nonce: string }) { + const scroll = useRef(null); + const twin = useRef(null); + const twinA = useRef(null); + const twinB = useRef(null); + const [tab, setTab] = useState<'A' | 'B'>('A'); + + useEffect(() => { + const logPhase = async (phase: string, tabNow: 'A' | 'B') => { + const viewport = await measured(scroll.current as unknown as Measurable | null); + const self = await measured(twin.current); + const tabRect = await measured(tabNow === 'A' ? twinA.current : twinB.current); + mark( + `secure-visible phase=${phase} viewport=${viewport} self=${self} tab=${tabNow}:${tabRect} ` + + `ratio=${PixelRatio.get()} nonce=${nonce}`, + ); + }; + (async () => { + await sleep(1500); + for (const [phase, y] of PHASES) { + scroll.current?.scrollTo({ y, animated: false }); + await sleep(SETTLE_MS); + await logPhase(phase, 'A'); + } + setTab('B'); + await sleep(SETTLE_MS); + await logPhase('tab-b', 'B'); + setTab('A'); + await sleep(SETTLE_MS); + await logPhase('tab-a', 'A'); + mark(`secure-visible done nonce=${nonce}`); + })().catch((error: unknown) => { + mark(`secure-visible threw ${String(error)} nonce=${nonce}`); + }); + }, [nonce]); + + return ( + + + + + + + + + + + + + + + + + ); +} + +/** Tick durations (ms) of the timed interval. */ +const tickDurations: number[] = []; +let timedIntervals = 0; +/** Ends the window in which new intervals are timed. */ +let endTimingWindow: (() => void) | undefined; + +/** + * Wraps `setInterval` until the returned function is called: an interval + * with the loop's period runs its callback timed. + */ +function timeLoopIntervals(): () => void { + const real = globalThis.setInterval; + const wrapped = ((callback: (...args: unknown[]) => void, ms?: number, ...args: unknown[]) => { + if (ms !== REMEASURE_MS) { + return real(callback, ms, ...args); + } + timedIntervals += 1; + return real(() => { + const start = performance.now(); + callback(...args); + tickDurations.push(performance.now() - start); + }, ms); + }) as typeof setInterval; + globalThis.setInterval = wrapped; + return () => { + globalThis.setInterval = real; + }; +} + +function percentile(sorted: readonly number[], p: number): number { + return sorted[Math.min(sorted.length - 1, Math.ceil((p / 100) * sorted.length) - 1)] ?? Number.NaN; +} + +const ROWS = 4; +const PER_ROW = 5; + +/** A view inside each secure view: the same ancestors, one host deeper. */ +const insides: Array = []; + +function PerfSecures({ rows, tall }: { rows: { current: Array }; tall: boolean }) { + // After every child's layout effect: the loop's interval has started. + useLayoutEffect(() => { + endTimingWindow?.(); + endTimingWindow = undefined; + }, []); + return ( + <> + {Array.from({ length: ROWS }, (_, row) => ( + { + rows.current[row] = view; + }} + horizontal + style={styles.row} + contentContainerStyle={styles.wide}> + {Array.from({ length: PER_ROW }, (__, i) => ( + + { + insides[row * PER_ROW + i] = inside; + }} + collapsable={false} + style={styles.fill} + /> + + ))} + + ))} + + ); +} + +function SecurePerfProbe({ nonce }: { nonce: string }) { + const outer = useRef(null); + const rows = useRef>([]); + const [mountedSecures, setMountedSecures] = useState(false); + // secure-perf's `clipped` mode: every view taller than its row, so all 20 + // are cut by their row's viewport and on the per-frame re-measure. + const [tall, setTall] = useState(false); + + useEffect(() => { + endTimingWindow = timeLoopIntervals(); + setMountedSecures(true); + }, []); + + useEffect(() => { + if (!mountedSecures) { + return undefined; + } + let cancelled = false; + let scroller: ReturnType | undefined; + const offsets = [0, 250, 700, 1200]; + let step = 0; + const f = (value: number) => value.toFixed(3); + const report = (mode: string, durations: readonly number[]) => { + const sorted = [...durations].sort((a, b) => a - b); + const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; + mark( + `secure-perf result mode=${mode} ticks=${sorted.length} intervals=${timedIntervals} ` + + `secures=${ROWS * PER_ROW} p50=${f(percentile(sorted, 50))} p95=${f(percentile(sorted, 95))} ` + + `p99=${f(percentile(sorted, 99))} max=${f(sorted[sorted.length - 1] ?? Number.NaN)} mean=${f(mean)} ` + + `dev=${String(__DEV__)} nonce=${nonce}`, + ); + }; + // Ticks of the shared interval, and every frame callback: the package's + // per-frame re-measure is the only one requesting frames in these modes. + const timeFor = async (mode: string) => { + tickDurations.length = 0; + const frameDurations: number[] = []; + const realFrame = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (callback => + realFrame(timestamp => { + const start = performance.now(); + callback(timestamp); + frameDurations.push(performance.now() - start); + })) as typeof requestAnimationFrame; + try { + await sleep(PERF_MS); + } finally { + globalThis.requestAnimationFrame = realFrame; + } + report(mode, tickDurations); + report(`${mode}-frames`, frameDurations); + }; + (async () => { + await sleep(PERF_WARMUP_MS); + // Nothing moves: the cost of re-measuring alone. + await timeFor('static'); + if (cancelled) { + return; + } + // Everything moves every 400 ms: re-measuring plus the publishes. + scroller = setInterval(() => { + step += 1; + outer.current?.scrollTo({ y: offsets[step % offsets.length], animated: false }); + rows.current.forEach((row, index) => row?.scrollTo({ x: ((step + index) % 3) * 300, animated: false })); + }, 400); + await timeFor('scrolling'); + clearInterval(scroller); + if (cancelled) { + return; + } + // All 20 partly clipped and moving: every one on the per-frame + // re-measure, the worst case. + setTall(true); + outer.current?.scrollTo({ y: 0, animated: false }); + rows.current.forEach(row => row?.scrollTo({ x: 0, animated: false })); + await sleep(PERF_WARMUP_MS); + const nudges = [0, 20, 40]; + scroller = setInterval(() => { + step += 1; + outer.current?.scrollTo({ y: nudges[step % nudges.length], animated: false }); + rows.current.forEach((row, index) => row?.scrollTo({ x: ((step + index) % 2) * 20, animated: false })); + }, 400); + await timeFor('clipped'); + clearInterval(scroller); + setTall(false); + await sleep(PERF_WARMUP_MS); + if (cancelled) { + return; + } + // An upper bound of what 20 withdrawn views cost every frame: one full + // isHidden each (props reads only, no per-pass cache). A withdrawn + // view's frame callback runs only screensHide, the screens part of it, + // cached per screen in a measure pass; these views have no screen above + // them, so screensHide would return at once and time nothing. + const ancestries = insides.map(inside => ancestryOfInstance(inside as object)); + const watched = ancestries.length; + tickDurations.length = 0; + const until = Date.now() + WATCH_MS; + await new Promise(resolve => { + const frame = () => { + const start = performance.now(); + for (const ancestry of ancestries) { + isHidden(ancestry); + } + tickDurations.push(performance.now() - start); + if (Date.now() < until && !cancelled) { + requestAnimationFrame(frame); + } else { + resolve(); + } + }; + requestAnimationFrame(frame); + }); + const sorted = [...tickDurations].sort((a, b) => a - b); + const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; + mark( + `secure-perf result mode=watch ticks=${sorted.length} intervals=${timedIntervals} ` + + `secures=${watched} p50=${f(percentile(sorted, 50))} p95=${f(percentile(sorted, 95))} ` + + `p99=${f(percentile(sorted, 99))} max=${f(sorted[sorted.length - 1] ?? Number.NaN)} mean=${f(mean)} ` + + `dev=${String(__DEV__)} nonce=${nonce}`, + ); + })().catch((error: unknown) => { + mark(`secure-perf threw ${String(error)} nonce=${nonce}`); + }); + return () => { + cancelled = true; + clearInterval(scroller); + }; + }, [mountedSecures, nonce]); + + return ( + + + + + {mountedSecures && } + + + + + ); +} + +/** secure-perf-many: ScrollViews, and secure views in each. */ +const MANY_SCROLLS = 4; +const MANY_PER_SCROLL = 50; + +function ManySecures() { + // After every child's layout effect: the loop's interval has started. + useLayoutEffect(() => { + endTimingWindow?.(); + endTimingWindow = undefined; + }, []); + return ( + <> + {Array.from({ length: MANY_SCROLLS }, (_, scroll) => ( + + {Array.from({ length: MANY_PER_SCROLL }, (__, i) => ( + + ))} + + ))} + + ); +} + +/** + * secure-perf-many: 200 `` in four still ScrollViews, each + * 100 dp tall with 50 views 40 dp tall stacked in it, so every view is + * partly clipped or wholly out of sight and nothing moves. Ticks and frame + * callbacks are timed for 40 s after the views have settled. + */ +function SecurePerfManyProbe({ nonce }: { nonce: string }) { + const [mountedSecures, setMountedSecures] = useState(false); + + useEffect(() => { + endTimingWindow = timeLoopIntervals(); + setMountedSecures(true); + }, []); + + useEffect(() => { + if (!mountedSecures) { + return undefined; + } + let cancelled = false; + const f = (value: number) => value.toFixed(3); + const report = (mode: string, durations: readonly number[]) => { + const sorted = [...durations].sort((a, b) => a - b); + const mean = sorted.reduce((sum, value) => sum + value, 0) / sorted.length; + mark( + `secure-perf result mode=${mode} ticks=${sorted.length} intervals=${timedIntervals} ` + + `secures=${MANY_SCROLLS * MANY_PER_SCROLL} p50=${f(percentile(sorted, 50))} ` + + `p95=${f(percentile(sorted, 95))} p99=${f(percentile(sorted, 99))} ` + + `max=${f(sorted[sorted.length - 1] ?? Number.NaN)} mean=${f(mean)} dev=${String(__DEV__)} nonce=${nonce}`, + ); + }; + (async () => { + await sleep(PERF_WARMUP_MS); + tickDurations.length = 0; + const frameDurations: number[] = []; + const realFrame = globalThis.requestAnimationFrame; + globalThis.requestAnimationFrame = (callback => + realFrame(timestamp => { + const start = performance.now(); + callback(timestamp); + frameDurations.push(performance.now() - start); + })) as typeof requestAnimationFrame; + try { + await sleep(PERF_MS); + } finally { + globalThis.requestAnimationFrame = realFrame; + } + if (cancelled) { + return; + } + report('many', tickDurations); + report('many-frames', frameDurations); + })().catch((error: unknown) => { + mark(`secure-perf threw ${String(error)} nonce=${nonce}`); + }); + return () => { + cancelled = true; + }; + }, [mountedSecures, nonce]); + + return {mountedSecures && }; +} + +export function SecureVisibleStage({ scenario, nonce }: { scenario: SecureVisibleScenario; nonce: string }) { + if (scenario === 'secure-visible') { + return ; + } + return scenario === 'secure-perf' ? : ; +} + +const styles = StyleSheet.create({ + stage: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: '#FFFFFF' }, + fill: { flex: 1 }, + tall: { height: 2000 }, + perfTall: { height: 3000 }, + clipOuter: { overflow: 'hidden', margin: 10, height: 2800 }, + clipInner: { overflow: 'hidden', margin: 10, flex: 1 }, + row: { height: 70, flexGrow: 0, marginBottom: 10 }, + wide: { width: 1500, height: 70 }, + perfSecure: { position: 'absolute', top: 10, width: 70, height: 40, backgroundColor: '#EEEEEE' }, + perfSecureTall: { height: 80 }, + manyScroll: { height: 100, flexGrow: 0, marginBottom: 20 }, + manyContent: { height: 2000 }, + manySecure: { height: 40, backgroundColor: '#EEEEEE' }, +}); diff --git a/packages/react-native/src/secure/BugseeSecure.tsx b/packages/react-native/src/secure/BugseeSecure.tsx index c113e3f5..af9ab739 100644 --- a/packages/react-native/src/secure/BugseeSecure.tsx +++ b/packages/react-native/src/secure/BugseeSecure.tsx @@ -2,10 +2,7 @@ import { useLayoutEffect, useRef } from 'react'; import type { ComponentRef, ReactElement, ReactNode } from 'react'; import { View } from 'react-native'; import type { LayoutChangeEvent, ViewProps } from 'react-native'; -import { addMeasurer } from './measureLoop'; -import { clearOwner, setOwnerRectangles } from './registry'; -import { surfaceOfInstance } from './surface'; -import { errorName } from '../errorName'; +import { SecureTracker } from './tracker'; export interface BugseeSecureProps extends ViewProps { /** While false, nothing is redacted and nothing is measured. Default true. */ @@ -23,8 +20,41 @@ export interface BugseeSecureProps extends ViewProps { * never disturbs what `Bugsee.setSecureRectangles` set or another instance * holds. * + * Only the part that is on screen is published: the rectangle is cut to every + * ancestor that clips it, up to its surface's root (a ScrollView's viewport, + * or a view styled `overflow: 'hidden'`). A view scrolled fully out of sight + * publishes nothing, so it does not mask, or drop the taps on, whatever is + * shown there instead (a bottom tab bar, say). + * + * Nothing is measured, and in the end nothing published, while a + * react-native-screens screen above it is inactive in the committed tree + * (`activityState` 0 under a screens container, such as an inactive tab that + * stays mounted): that hides a view without changing its layout. A view + * hidden by `display: 'none'` or a hidden `` measures 0x0, which + * publishes nothing anyway. + * + * Coverage is added at once and removed late. What it measures describes the + * shadow tree, which native draws a frame or more later (a LayoutAnimation + * only at its end), so what it stopped covering, by shrinking, moving or + * being hidden, stays published for `SECURE_SHRINK_HOLD_MS`. Growth is never + * delayed. + * + * Kept up to date within a frame where it matters, at a cost bounded by what + * moves: a view that moves, was just shown or is clipped re-measures every + * frame until it has stayed put for `STEADY_FRAMES` frames; a clipped view + * that is still is woken the frame its ScrollView's content (watched once a + * frame for every view under it) moves; a withdrawn view asks every frame, + * once per screen, whether its screens still hide it, and is back the frame + * they do not. + * * Fails closed: a measurement that throws or is rejected leaves the last - * rectangle published. Only unmounting or `enabled={false}` removes it. + * rectangle published, and an ancestor that cannot be read or measured cuts + * nothing off it. Only unmounting or `enabled={false}` removes it at once. + * + * Known limit: how an ancestor clips is read from its committed React props. + * An `overflow` changed natively, through `setNativeProps` or a native-driven + * style, is not seen: a view still believed clipped by an ancestor that no + * longer clips publishes only the part inside the old clip. * * Fabric `measureInWindow` is relative to the measured node's nearest * `RootNodeKind` ancestor: the app's root, or the content of the `` @@ -43,46 +73,10 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { // This instance's registry key. An object, so it can never collide with a // manual owner or with another instance. const token = useRef({}).current; - // Whether a measurement arriving now may still publish. Paper answers - // measureInWindow asynchronously; an answer landing after unmount or - // disable must not bring the region back. - const active = useRef(false); - const warned = useRef(false); - // The surface this view is measured in. Read once per mount: a mounted - // host never changes ancestors. - const surface = useRef(null); - - // Reads refs only, so every render's copy does the same thing; the loop - // keeps the one from the render that enabled it. - const measure = (): void => { - const failed = (error: unknown): void => { - if (!warned.current) { - warned.current = true; - console.warn('[Bugsee] BugseeSecure could not measure', errorName(error)); - } - }; - - const view = ref.current; - if (!active.current || view === null) { - return; - } - try { - surface.current ??= surfaceOfInstance(view); - const measuredIn = surface.current; - view.measureInWindow((x, y, width, height) => { - if (!active.current) { - return; - } - try { - setOwnerRectangles(token, 0, [{ x, y, width, height }], measuredIn); - } catch (error) { - failed(error); - } - }); - } catch (error) { - failed(error); - } - }; + // Measures and publishes for this instance while it is mounted and + // enabled. It reads the view through the ref, so one is as good as any. + const tracker = useRef(null); + tracker.current ??= new SecureTracker(() => ref.current, token); // A layout effect, not a passive one: it runs in the commit, so on Fabric // the first rectangle is registered before the first frame is painted @@ -91,22 +85,15 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { if (!enabled) { return undefined; } - active.current = true; - measure(); - const removeMeasurer = addMeasurer(measure); - return () => { - active.current = false; - surface.current = null; - removeMeasurer(); - clearOwner(token); - }; - // Only `enabled`: `measure` and `token` read refs, so the first copy of - // each is as good as any later one. + const current = tracker.current as SecureTracker; + current.start(); + return () => current.stop(); + // Only `enabled`: the tracker is created once per instance. }, [enabled]); const handleLayout = (event: LayoutChangeEvent): void => { onLayout?.(event); - measure(); + (tracker.current as SecureTracker).measure(); }; return ; diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.review.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.review.test.tsx new file mode 100644 index 00000000..d78d580d --- /dev/null +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.review.test.tsx @@ -0,0 +1,486 @@ +/** + * The review of PR #87 (#86): every way the visible-rectangle logic could + * withdraw, or cut short, the region of a secure view that is on screen. + * Main never withdrew a region, so each of these would be a leak the fix + * introduced. Each test failed against the first version of the fix. + */ +import { act } from 'react'; +import type { ReactElement } from 'react'; +import { create } from 'react-test-renderer'; +import type { ReactTestRenderer } from 'react-test-renderer'; +import { BugseeSecure } from '../BugseeSecure'; +import { MODAL_SCREEN_HOST_TYPE, SCREEN_HOST_TYPE } from '../ancestry'; +import { STEADY_FRAMES } from '../tracker'; +import { native } from '../../__mocks__/native'; +import { FiberTag } from '../../viewtree/fiber'; +import type { FiberLike, WindowRect } from '../../viewtree/fiber'; +import { + ancestorOfType, + commitProps, + composite, + fabricHost, + fakeRenderer, + offscreen, + pairWith, + rect, + root, + screenContainer, + scrollView, + self, + selfIn, + selvesIn, + view, +} from './fakeAncestors'; +import type { FakeRenderer } from './fakeAncestors'; + +jest.mock('react-native', () => ({ + View: 'View', + PixelRatio: { get: () => 2 }, + Platform: { OS: 'android' }, +})); +jest.mock('../../NativeBugsee', () => require('../../__mocks__/native').nativeMock); + +let renderer: FakeRenderer; +// Delegates, so the mock module (cached for the whole file) reaches each +// test's own fake renderer. +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + getPublicInstanceFromInternalInstanceHandle: (fiber: FiberLike) => + renderer.getPublicInstanceFromInternalInstanceHandle(fiber), +})); + +// See BugseeSecure.test.tsx: React Native's own test-environment flags. +(globalThis as { IS_REACT_NATIVE_TEST_ENVIRONMENT?: boolean }).IS_REACT_NATIVE_TEST_ENVIRONMENT = true; +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type Callback = (x: number, y: number, width: number, height: number) => void; + +/** Where the secure view itself measures to; `moveTo` the self fiber's rect. */ +let selfRect: WindowRect; +let measureInWindow: jest.Mock; +let mounted: ReactTestRenderer[] = []; + +beforeEach(() => { + jest.useFakeTimers(); + native.reset(); + renderer = fakeRenderer(); + selfRect = rect(10, 100, 200, 50); + measureInWindow = jest.fn((callback: Callback) => callback(selfRect.x, selfRect.y, selfRect.width, selfRect.height)); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + act(() => mounted.forEach((mountedRenderer) => mountedRenderer.unmount())); + mounted = []; + expect(jest.getTimerCount()).toBe(0); + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +/** Mounts `element` with its View backed by `fiber`. */ +function render(fiber: FiberLike | undefined, element: ReactElement = ): ReactTestRenderer { + let testRenderer: ReactTestRenderer | undefined; + act(() => { + testRenderer = create(element, { + createNodeMock: () => ({ measureInWindow, ...(fiber === undefined ? {} : { __internalInstanceHandle: fiber }) }), + unstable_isConcurrent: true, + } as Parameters[1]); + }); + mounted.push(testRenderer as ReactTestRenderer); + return testRenderer as ReactTestRenderer; +} + +function tick(times = 1): void { + act(() => { + jest.advanceTimersByTime(100 * times); + }); +} + +/** A window rectangle in points as the registry publishes it, at PixelRatio 2. */ +const px = ({ x, y, width, height }: WindowRect): number[] => [2 * x, 2 * y, 2 * (x + width), 2 * (y + height)]; + +const lastPublished = (): unknown => native.setSecureRectangles.mock.lastCall; + +/** + * Nothing but empty sets reached the bridge, at most once: the registry + * sends only a change, and the lane may already be empty from an earlier + * test (its state outlives each test, as a JS runtime's does). + */ +function publishedOnlyNothing(): void { + const calls = native.setSecureRectangles.mock.calls; + expect(calls.filter(([, flat]) => (flat as unknown[]).length > 0)).toEqual([]); + expect(calls.length).toBeLessThanOrEqual(1); +} + +/** A secure view under an RNSScreen with committed `activityState`, under a wrapper with `wrapperProps`. */ +function underScreen(activityState: unknown, wrapperProps: Record = {}): FiberLike { + return selfIn( + root([ + screenContainer([ + composite( + [fabricHost(SCREEN_HOST_TYPE, rect(0, 0, 400, 800), { activityState }, [view([self()])])], + wrapperProps, + ), + ]), + ]), + ); +} + +describe('P0-1: hidden or clipped only by what is committed', () => { + // A transition rendered the screen inactive, then suspended (or was + // interrupted, or abandoned): the work-in-progress fiber and the shared + // canonical say 0, the committed fiber still says 2, and the screen shows. + it('a render in flight that makes a screen inactive withdraws nothing', () => { + const fiber = underScreen(2); + pairWith(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 0 }, true); + + render(fiber); + tick(3); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('a render in flight that sets overflow: hidden clips nothing', () => { + const fiber = selfIn(root([fabricHost('RCTView', rect(0, 0, 400, 110), {}, [self()])])); + pairWith(ancestorOfType(fiber, 'RCTView'), { style: { overflow: 'hidden' } }, true); + + render(fiber); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('a stale fiber that still says inactive withdraws nothing', () => { + const fiber = underScreen(2); + pairWith(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 0 }); + + render(fiber); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('a committed inactive screen withdraws it, whichever fiber of the pair was stored', () => { + const fiber = underScreen(0); + pairWith(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 2 }, true); + + render(fiber); + + expect(measureInWindow).not.toHaveBeenCalled(); + publishedOnlyNothing(); + }); + + // Fabric lays a display: 'none' subtree out at 0x0, which publishes + // nothing; props say nothing reliable about it (setNativeProps, Reanimated). + it("committed display: 'none' props alone withdraw nothing", () => { + render(selfIn(root([view([view([self()])], { style: { display: 'none' } })]))); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); + +describe('P0-2: an Animated activityState is not committed', () => { + it('a screen whose wrapper drives activityState with an Animated node stays masked', () => { + const animated = { __getValue: () => 0 }; + render(underScreen(0, { activityState: animated })); + tick(2); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + expect(measureInWindow).toHaveBeenCalled(); + }); + + it('a plain number on the wrapper is committed, and withdraws it', () => { + render(underScreen(0, { activityState: 0 })); + + publishedOnlyNothing(); + }); +}); + +describe('P0-3: a ScrollView styled overflow: visible draws outside its viewport', () => { + it('clips nothing', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 110), [self()])])); + commitProps(ancestorOfType(fiber, 'RCTScrollView'), { style: [{ flex: 1 }, { overflow: 'visible' }] }); + + render(fiber); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); + +describe('P0-4: nothing above a root-kind host clips or hides what is inside', () => { + it.each(['RNSFullWindowOverlay', 'RCTInputAccessoryView', 'RNSModalScreen', 'RNSSplitScreen'])( + 'stops at %s', + (type) => { + const overlay = fabricHost(type, rect(0, 0, 400, 800), {}, [view([self()])]); + const fiber = selfIn( + root([fabricHost(SCREEN_HOST_TYPE, null, { activityState: 0 }, [scrollView(rect(0, 0, 400, 110), [overlay])])]), + ); + + render(fiber); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + expect(renderer.measures).toEqual([]); + }, + ); +}); + +// Cursor review on #87: react-native-screens' iOS modals mount +// RNSModalScreen, which takes activityState like RNSScreen. +describe('a react-native-screens modal screen', () => { + it('withdraws it while inactive, and shows it again within a frame', () => { + const modal = fabricHost(MODAL_SCREEN_HOST_TYPE, rect(0, 0, 400, 800), { activityState: 0 }, [view([self()])]); + const fiber = selfIn(root([screenContainer([composite([modal])], 'RNSScreenStack')])); + render(fiber); + publishedOnlyNothing(); + + commitProps(ancestorOfType(fiber, MODAL_SCREEN_HOST_TYPE), { activityState: 2 }); + act(() => { + jest.advanceTimersByTime(17); + }); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); + +describe('P1-1: a hidden Offscreen can be a frozen screen that is still drawn', () => { + it('withdraws nothing under a hidden Offscreen', () => { + const frozen = fabricHost(SCREEN_HOST_TYPE, rect(0, 0, 400, 800), { activityState: 2 }, [view([self()])]); + const fiber = selfIn(root([{ ...offscreen(0, [frozen]), memoizedState: { baseLanes: 0, cachePool: null } }])); + + render(fiber); + tick(2); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); + +describe('P1-2: shown again, it is masked again within a frame', () => { + it('republishes in the next frame, not the next re-measure', () => { + const fiber = underScreen(0); + render(fiber); + publishedOnlyNothing(); + + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 2 }); + act(() => { + jest.advanceTimersByTime(17); + }); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('uses requestAnimationFrame where there is one, and cancels it on unmount', () => { + const frames: Array<() => void> = []; + const cancelled: number[] = []; + const g = globalThis as { requestAnimationFrame?: unknown; cancelAnimationFrame?: unknown }; + let handles = 0; + g.requestAnimationFrame = (callback: () => void) => { + frames.push(callback); + handles += 1; + return handles; + }; + g.cancelAnimationFrame = (handle: number) => cancelled.push(handle); + try { + const fiber = underScreen(0); + const testRenderer = render(fiber); + expect(frames).toHaveLength(1); + + // Still hidden: it watches the next frame too, measuring nothing. + act(() => frames.shift()?.()); + expect(frames).toHaveLength(1); + expect(measureInWindow).not.toHaveBeenCalled(); + + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 2 }); + act(() => frames.shift()?.()); + expect(lastPublished()).toEqual([0, px(selfRect)]); + // Just shown: on the per-frame re-measure until it has stayed put. + for (let frame = 0; frame < STEADY_FRAMES; frame += 1) { + expect(frames).toHaveLength(1); + act(() => frames.shift()?.()); + } + expect(frames).toHaveLength(0); + + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 0 }); + tick(); + expect(frames).toHaveLength(1); + act(() => testRenderer.unmount()); + mounted = mounted.filter((entry) => entry !== testRenderer); + expect(cancelled).toEqual([handles]); + } finally { + delete g.requestAnimationFrame; + delete g.cancelAnimationFrame; + } + }); +}); + +describe('P1-2: the frame fallback', () => { + const g = globalThis as { requestAnimationFrame?: unknown; cancelAnimationFrame?: unknown }; + + afterEach(() => { + delete g.requestAnimationFrame; + delete g.cancelAnimationFrame; + }); + + it.each([ + ['requestAnimationFrame without cancelAnimationFrame', 'requestAnimationFrame'], + ['cancelAnimationFrame without requestAnimationFrame', 'cancelAnimationFrame'], + ])('falls back to a timer with %s', (_, only) => { + const called = jest.fn(() => 1); + (g as Record)[only] = called; + const fiber = underScreen(0); + render(fiber); + + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 2 }); + act(() => { + jest.advanceTimersByTime(17); + }); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + expect(called).not.toHaveBeenCalled(); + }); +}); + +// Round 2: a part of a clipped view that scrolls into view must be masked +// within a frame. A clipped view re-measures every frame; a wholly visible one +// stays on the 100 ms tick. +describe('a clipped view follows a scroll into view within a frame', () => { + const g = globalThis as { requestAnimationFrame?: unknown; cancelAnimationFrame?: unknown }; + + afterEach(() => { + delete g.requestAnimationFrame; + delete g.cancelAnimationFrame; + }); + + function frame(): void { + act(() => { + jest.advanceTimersByTime(16); + }); + } + + it('publishes a view entering the viewport within one frame', () => { + selfRect = rect(10, 200, 200, 50); + render(selfIn(root([scrollView(rect(0, 0, 400, 130), [self()])]))); + publishedOnlyNothing(); + + selfRect = rect(10, 100, 200, 50); + frame(); + + expect(lastPublished()).toEqual([0, px(rect(10, 100, 200, 30))]); + }); + + it('follows a horizontal scroll within one frame, clipped only in width', () => { + render(selfIn(root([scrollView(rect(0, 0, 110, 800), [self()], 'AndroidHorizontalScrollView')]))); + expect(lastPublished()).toEqual([0, px(rect(10, 100, 100, 50))]); + + selfRect = rect(-40, 100, 200, 50); + frame(); + + // The new part at once, the old one held. + expect(lastPublished()).toEqual([0, [...px(rect(0, 100, 110, 50)), ...px(rect(10, 100, 100, 50))]]); + }); + + it('leaves the per-frame re-measure once the view is wholly visible', () => { + selfRect = rect(10, 100, 200, 50); + render(selfIn(root([scrollView(rect(0, 0, 400, 130), [self()])]))); + + selfRect = rect(10, 50, 200, 50); + frame(); + expect(lastPublished()).toEqual([0, [...px(selfRect), ...px(rect(10, 100, 200, 30))]]); + // Still for STEADY_FRAMES frames, then off the per-frame re-measure. + act(() => { + jest.advanceTimersByTime(16 * STEADY_FRAMES); + }); + const settled = measureInWindow.mock.calls.length; + + // Frames between ticks measure nothing; a tick measures once. + act(() => { + jest.advanceTimersByTime(64); + }); + tick(); + expect(measureInWindow.mock.calls.length - settled).toBeLessThanOrEqual(2); + expect(measureInWindow.mock.calls.length - settled).toBeGreaterThanOrEqual(1); + }); + + it('a clipped view is measured once a frame, not again on the tick', () => { + render(selfIn(root([scrollView(rect(0, 0, 400, 130), [self()])]))); + const before = measureInWindow.mock.calls.length; + + // 100 ms: six frames and one tick. + tick(); + + expect(measureInWindow.mock.calls.length - before).toBe(6); + }); + + it('many clipped views share one frame callback and one measure pass', () => { + const frames: Array<() => void> = []; + g.requestAnimationFrame = (callback: () => void) => frames.push(callback); + g.cancelAnimationFrame = () => undefined; + const fibers = selvesIn(root([scrollView(rect(0, 0, 400, 130), [self(), self(), self()])])); + let testRenderer: ReactTestRenderer | undefined; + act(() => { + testRenderer = create( + <> + {fibers.map((_, index) => ( + + ))} + , + { + createNodeMock: (element: { props: { testID?: string } }) => ({ + measureInWindow, + __internalInstanceHandle: fibers[Number(element.props.testID)], + }), + unstable_isConcurrent: true, + } as Parameters[1], + ); + }); + mounted.push(testRenderer as ReactTestRenderer); + expect(frames).toHaveLength(1); + const ancestorsBefore = renderer.measures.length; + const selfBefore = measureInWindow.mock.calls.length; + + act(() => frames.shift()?.()); + + expect(measureInWindow.mock.calls.length - selfBefore).toBe(3); + // The ScrollView and its content (watched), once each. + expect(renderer.measures.length - ancestorsBefore).toBe(2); + expect(frames).toHaveLength(1); + }); +}); + +describe('P3-1: a read error clips nothing and hides nothing', () => { + it('leaves the whole view masked when an ancestor further up cannot be read', () => { + const fiber = selfIn(root([view([scrollView(rect(0, 0, 400, 110), [self()])])])); + commitProps(ancestorOfType(fiber, 'RCTView'), { + get style(): never { + throw new Error('torn props'); + }, + }); + + render(fiber); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('leaves it masked when the stored path is not in the committed tree', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 110), [self()])])); + const scroll = ancestorOfType(fiber, 'RCTScrollView'); + (scroll.return as FiberLike).child = null; + + render(fiber); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('leaves it masked with no fiber root to resolve from', () => { + const children = [scrollView(rect(0, 0, 400, 110), [self()])]; + const fiber = selfIn({ tag: FiberTag.HostRoot, type: null, stateNode: {}, children }); + + render(fiber); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); + +describe('P3-2: an ancestor measured 0/0/0/0 is unknown', () => { + it('clips nothing itself, while the others still clip', () => { + render(selfIn(root([scrollView(rect(0, 0, 400, 120), [scrollView(rect(0, 0, 0, 0), [self()])])]))); + + expect(lastPublished()).toEqual([0, px(rect(10, 100, 200, 20))]); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.round3.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.round3.test.tsx new file mode 100644 index 00000000..ae3806e6 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.round3.test.tsx @@ -0,0 +1,386 @@ +/** + * Round 3 of the review of #87 (#86). The shadow tree leads what native + * draws, so coverage is added at once and removed only after + * SECURE_SHRINK_HOLD_MS; a static clipped or hidden view costs nothing per + * frame beyond its share of a watched ScrollView; and the walk's last gaps + * are closed. Each test failed against 42eb416, except the controls marked + * so. + */ +import { act } from 'react'; +import type { ReactElement } from 'react'; +import { create } from 'react-test-renderer'; +import type { ReactTestRenderer } from 'react-test-renderer'; +import { BugseeSecure } from '../BugseeSecure'; +import { SCREEN_HOST_TYPE } from '../ancestry'; +import { SECURE_SHRINK_HOLD_MS } from '../hold'; +import { native } from '../../__mocks__/native'; +import { FiberTag } from '../../viewtree/fiber'; +import type { FiberLike, WindowRect } from '../../viewtree/fiber'; +import { + ancestorOfType, + commitProps, + fabricHost, + fakeRenderer, + moveTo, + rect, + root, + screenContainer, + scrollView, + self, + selfIn, + selvesIn, + view, +} from './fakeAncestors'; +import type { FakeRenderer } from './fakeAncestors'; + +jest.mock('react-native', () => ({ + View: 'View', + PixelRatio: { get: () => 2 }, + Platform: { OS: 'android' }, +})); +jest.mock('../../NativeBugsee', () => require('../../__mocks__/native').nativeMock); + +let renderer: FakeRenderer; +// Delegates, so the mock module (cached for the whole file) reaches each +// test's own fake renderer. +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + getPublicInstanceFromInternalInstanceHandle: (fiber: FiberLike) => + renderer.getPublicInstanceFromInternalInstanceHandle(fiber), +})); + +// See BugseeSecure.test.tsx: React Native's own test-environment flags. +(globalThis as { IS_REACT_NATIVE_TEST_ENVIRONMENT?: boolean }).IS_REACT_NATIVE_TEST_ENVIRONMENT = true; +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type Callback = (x: number, y: number, width: number, height: number) => void; + +/** Where the secure view itself measures to; `moveTo` the self fiber's rect. */ +let selfRect: WindowRect; +let measureInWindow: jest.Mock; +let mounted: ReactTestRenderer[] = []; + +beforeEach(() => { + jest.useFakeTimers(); + native.reset(); + renderer = fakeRenderer(); + selfRect = rect(10, 100, 200, 50); + measureInWindow = jest.fn((callback: Callback) => callback(selfRect.x, selfRect.y, selfRect.width, selfRect.height)); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + act(() => mounted.forEach((mountedRenderer) => mountedRenderer.unmount())); + mounted = []; + expect(jest.getTimerCount()).toBe(0); + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +/** Mounts `element` with its View backed by `fiber`. */ +function render(fiber: FiberLike | undefined, element: ReactElement = ): ReactTestRenderer { + let testRenderer: ReactTestRenderer | undefined; + act(() => { + testRenderer = create(element, { + createNodeMock: () => ({ measureInWindow, ...(fiber === undefined ? {} : { __internalInstanceHandle: fiber }) }), + unstable_isConcurrent: true, + } as Parameters[1]); + }); + mounted.push(testRenderer as ReactTestRenderer); + return testRenderer as ReactTestRenderer; +} + +function tick(times = 1): void { + act(() => { + jest.advanceTimersByTime(100 * times); + }); +} + +/** A window rectangle in points as the registry publishes it, at PixelRatio 2. */ +const px = ({ x, y, width, height }: WindowRect): number[] => [2 * x, 2 * y, 2 * (x + width), 2 * (y + height)]; + +const lastPublished = (): unknown => native.setSecureRectangles.mock.lastCall; + + +function frames(count = 1): void { + act(() => { + jest.advanceTimersByTime(16 * count); + }); +} + +function wait(ms: number): void { + act(() => { + jest.advanceTimersByTime(ms); + }); +} + +/** Mounts one BugseeSecure per fiber, each backed by its own. */ +function renderAll(fibers: FiberLike[]): void { + let testRenderer: ReactTestRenderer | undefined; + act(() => { + testRenderer = create( + <> + {fibers.map((_, index) => ( + + ))} + , + { + createNodeMock: (element: { props: { testID?: string } }) => ({ + measureInWindow, + __internalInstanceHandle: fibers[Number(element.props.testID)], + }), + unstable_isConcurrent: true, + } as Parameters[1], + ); + }); + mounted.push(testRenderer as ReactTestRenderer); +} + +/** The first rectangle of the last publish on display 0, as [left, top, right, bottom]. */ +const firstPublished = (): number[] => (native.setSecureRectangles.mock.lastCall?.[1] as number[]).slice(0, 4); + +/** Every rectangle list that reached the bridge on display 0, in order. */ +const publishes = (): unknown[] => native.setSecureRectangles.mock.calls.map(([, flat]) => flat); + +/** A secure view under an RNSScreen with committed `activityState`, in a container. */ +function underScreen(activityState: number): FiberLike { + const screen = fabricHost(SCREEN_HOST_TYPE, rect(0, 0, 400, 800), { activityState }, [view([self()])]); + return selfIn(root([screenContainer([screen])])); +} + +describe('P1: coverage is added at once and removed late', () => { + it('a clipping ancestor that shrinks keeps the old rectangle until the hold expires', () => { + const clipping = fabricHost('RCTView', rect(0, 0, 400, 800), { style: { overflow: 'hidden' } }, [self()]); + const fiber = selfIn(root([clipping])); + render(fiber); + + moveTo(ancestorOfType(fiber, 'RCTView'), rect(0, 0, 400, 120)); + tick(); + expect(lastPublished()).toEqual([0, [...px(rect(10, 100, 200, 20)), ...px(selfRect)]]); + + wait(SECURE_SHRINK_HOLD_MS - 200); + expect(lastPublished()).toEqual([0, [...px(rect(10, 100, 200, 20)), ...px(selfRect)]]); + wait(400); + expect(lastPublished()).toEqual([0, px(rect(10, 100, 200, 20))]); + }); + + it('an inactive screen withdraws the view only after it has been hidden for the hold', () => { + const fiber = underScreen(2); + render(fiber); + + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 0 }); + tick(); + wait(SECURE_SHRINK_HOLD_MS - 200); + expect(lastPublished()).toEqual([0, px(selfRect)]); + + wait(400); + expect(lastPublished()).toEqual([0, []]); + }); + + it('hidden and shown again within the hold, it is never withdrawn', () => { + const fiber = underScreen(2); + render(fiber); + + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 0 }); + tick(); + wait(250); + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 2 }); + frames(); + wait(1_000); + + expect(publishes().some((flat) => (flat as unknown[]).length === 0)).toBe(false); + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('growth is never delayed (control)', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 120), [self()])])); + render(fiber); + + moveTo(ancestorOfType(fiber, 'RCTScrollView'), rect(0, 0, 400, 800)); + frames(); + + expect(firstPublished()).toEqual(px(selfRect)); + }); + + it('unmounting and disabling still clear at once (control)', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 800), [self()])])); + const testRenderer = render(fiber); + moveTo(ancestorOfType(fiber, 'RCTScrollView'), rect(0, 0, 400, 120)); + tick(); + + act(() => testRenderer.update()); + + expect(lastPublished()).toEqual([0, []]); + }); +}); + +describe('P2: a static clipped or hidden view costs nothing per frame', () => { + it('many still, clipped views measure nothing per frame but their shared ScrollView content', () => { + const fibers = selvesIn(root([scrollView(rect(0, 0, 400, 120), Array.from({ length: 20 }, () => self()))])); + renderAll(fibers); + // Settle: STEADY_FRAMES still frames, then parked; then just past the tick + // at 200 ms, so the five frames below (to 285 ms) hold no tick. + frames(12); + wait(13); + const selfBefore = measureInWindow.mock.calls.length; + const ancestorsBefore = renderer.measures.length; + + frames(5); + + expect(measureInWindow.mock.calls.length - selfBefore).toBe(0); + // The ScrollView's content and the ScrollView itself, watched once a frame. + expect(renderer.measures.slice(ancestorsBefore).map((fiber) => fiber.type)).toEqual( + Array(5).fill(['RCTScrollContentView', 'RCTScrollView']).flat(), + ); + }); + + it('a parked view is woken, and follows, the frame its ScrollView content moves (control)', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 120), [self()])])); + render(fiber); + // Parked, and just past the 200 ms tick: the frame below holds no tick. + frames(12); + wait(13); + + moveTo(ancestorOfType(fiber, 'RCTScrollContentView'), rect(0, -5040, 400, 9000)); + selfRect = rect(10, 60, 200, 50); + const before = measureInWindow.mock.calls.length; + frames(); + + expect(measureInWindow.mock.calls.length - before).toBe(1); + + expect(firstPublished()).toEqual(px(rect(10, 60, 200, 50))); + }); + + it('the tick re-checks every parked view, by measuring each alone', () => { + const fibers = selvesIn(root([scrollView(rect(0, 0, 400, 120), Array.from({ length: 40 }, () => self()))])); + renderAll(fibers); + frames(12); + wait(100); + const selfBefore = measureInWindow.mock.calls.length; + + wait(100); + + expect(measureInWindow.mock.calls.length - selfBefore).toBe(40); + }); + + it('a layout event on a parked view that did not move leaves it parked', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 120), [self()])])); + const testRenderer = render(fiber); + frames(12); + wait(13); + + const view = testRenderer.root.findByType('View' as never); + act(() => view.props.onLayout({ nativeEvent: {} })); + const before = measureInWindow.mock.calls.length; + frames(5); + + expect(measureInWindow.mock.calls.length).toBe(before); + }); + + it('a parked view the tick finds moved is measured again (control)', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 120), [self()])])); + render(fiber); + frames(12); + + selfRect = rect(10, 80, 200, 50); + tick(); + + expect(firstPublished()).toEqual(px(rect(10, 80, 200, 40))); + }); + + it('hidden views ask only about their screen each frame, not about what is below it', () => { + const views = Array.from({ length: 10 }, () => view([self()])); + const screen = fabricHost(SCREEN_HOST_TYPE, null, { activityState: 0 }, views); + const fibers = selvesIn(root([screenContainer([screen])])); + renderAll(fibers); + const screenFiber = ancestorOfType(fibers[0]!, SCREEN_HOST_TYPE); + const children = screenFiber.child; + let reads = 0; + Object.defineProperty(screenFiber, 'child', { + get(): FiberLike | null { + reads += 1; + return children; + }, + }); + + frames(5); + + expect(reads).toBe(0); + expect(measureInWindow).not.toHaveBeenCalled(); + }); +}); + +// Cursor review of ec4913f: a parked view must still be hidden by a +// screen going inactive, and must follow its viewport growing. +describe('parked views', () => { + it('a parked view under a screen that goes inactive is withdrawn after the hold', () => { + const screen = fabricHost(SCREEN_HOST_TYPE, rect(0, 0, 400, 800), { activityState: 2 }, [ + scrollView(rect(0, 0, 400, 120), [self()]), + ]); + const fiber = selfIn(root([screenContainer([screen])])); + render(fiber); + frames(12); + expect(lastPublished()).toEqual([0, px(rect(10, 100, 200, 20))]); + + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 0 }); + frames(); + const before = measureInWindow.mock.calls.length; + wait(SECURE_SHRINK_HOLD_MS + 200); + + expect(lastPublished()).toEqual([0, []]); + // Hidden: measured no more. + expect(measureInWindow.mock.calls.length).toBe(before); + }); + + it('a parked view follows its ScrollView viewport growing, within a frame', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 120), [self()])])); + render(fiber); + frames(12); + wait(13); + + // The viewport grows; the content does not move, the view does not lay out. + moveTo(ancestorOfType(fiber, 'RCTScrollView'), rect(0, 0, 400, 800)); + frames(); + + expect(firstPublished()).toEqual(px(selfRect)); + }); +}); + +describe('P3: the walk and the loop', () => { + it('(1) nothing above a HostPortal clips the view', () => { + const portal = { tag: FiberTag.HostPortal, type: null, children: [view([self()])] }; + render(selfIn(root([scrollView(rect(0, 0, 400, 110), [portal])]))); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('(4) activityState 0 hides nothing outside a screens container', () => { + const screen = fabricHost(SCREEN_HOST_TYPE, rect(0, 0, 400, 800), { activityState: 0 }, [view([self()])]); + render(selfIn(root([view([screen])]))); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('(5) stays on the per-frame re-measure until still for STEADY_FRAMES frames', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 120), [self()])])); + render(fiber); + // Wholly visible now: still measured every frame, until it has stayed put. + moveTo(ancestorOfType(fiber, 'RCTScrollView'), rect(0, 0, 400, 800)); + frames(); + const before = measureInWindow.mock.calls.length; + + frames(5); + expect(measureInWindow.mock.calls.length - before).toBe(5); + frames(10); + // To 288 ms: frames, and no tick (the next is at 300 ms). + const settled = measureInWindow.mock.calls.length; + frames(2); + expect(measureInWindow.mock.calls.length).toBe(settled); + }); + + it('(6) nothing above a react-native-screens form sheet host clips the view', () => { + const sheet = fabricHost('RNSFormSheetHost', rect(0, 0, 400, 800), {}, [view([self()])]); + render(selfIn(root([scrollView(rect(0, 0, 400, 110), [sheet])]))); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.round4.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.round4.test.tsx new file mode 100644 index 00000000..bff67332 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.round4.test.tsx @@ -0,0 +1,221 @@ +/** + * Round 4 of the review of #87 (#86): parked views must not under-mask. + * R1 and R2 are the reviewer's reproductions. Each test here failed against + * 51c6024. + */ +import { act } from 'react'; +import type { ReactElement } from 'react'; +import { create } from 'react-test-renderer'; +import type { ReactTestRenderer } from 'react-test-renderer'; +import { BugseeSecure } from '../BugseeSecure'; +import { SECURE_SHRINK_HOLD_MS } from '../hold'; +import { native } from '../../__mocks__/native'; +import type { FiberLike, WindowRect } from '../../viewtree/fiber'; +import { + ancestorOfType, + commitProps, + fabricHost, + fakeRenderer, + moveTo, + rect, + root, + scrollView, + self, + selfIn, + selvesIn, +} from './fakeAncestors'; +import type { FakeRenderer } from './fakeAncestors'; + +jest.mock('react-native', () => ({ + View: 'View', + PixelRatio: { get: () => 2 }, + Platform: { OS: 'android' }, +})); +jest.mock('../../NativeBugsee', () => require('../../__mocks__/native').nativeMock); + +let renderer: FakeRenderer; +// Delegates, so the mock module (cached for the whole file) reaches each +// test's own fake renderer. +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + getPublicInstanceFromInternalInstanceHandle: (fiber: FiberLike) => + renderer.getPublicInstanceFromInternalInstanceHandle(fiber), +})); + +// See BugseeSecure.test.tsx: React Native's own test-environment flags. +(globalThis as { IS_REACT_NATIVE_TEST_ENVIRONMENT?: boolean }).IS_REACT_NATIVE_TEST_ENVIRONMENT = true; +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type Callback = (x: number, y: number, width: number, height: number) => void; + +/** Where the secure view itself measures to; `moveTo` the self fiber's rect. */ +let selfRect: WindowRect; +let measureInWindow: jest.Mock; +let mounted: ReactTestRenderer[] = []; + +beforeEach(() => { + jest.useFakeTimers(); + native.reset(); + renderer = fakeRenderer(); + selfRect = rect(10, 100, 200, 50); + measureInWindow = jest.fn((callback: Callback) => callback(selfRect.x, selfRect.y, selfRect.width, selfRect.height)); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + act(() => mounted.forEach((mountedRenderer) => mountedRenderer.unmount())); + mounted = []; + expect(jest.getTimerCount()).toBe(0); + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +/** Mounts `element` with its View backed by `fiber`. */ +function render(fiber: FiberLike | undefined, element: ReactElement = ): ReactTestRenderer { + let testRenderer: ReactTestRenderer | undefined; + act(() => { + testRenderer = create(element, { + createNodeMock: () => ({ measureInWindow, ...(fiber === undefined ? {} : { __internalInstanceHandle: fiber }) }), + unstable_isConcurrent: true, + } as Parameters[1]); + }); + mounted.push(testRenderer as ReactTestRenderer); + return testRenderer as ReactTestRenderer; +} + +function tick(times = 1): void { + act(() => { + jest.advanceTimersByTime(100 * times); + }); +} + +/** A window rectangle in points as the registry publishes it, at PixelRatio 2. */ +const px = ({ x, y, width, height }: WindowRect): number[] => [2 * x, 2 * y, 2 * (x + width), 2 * (y + height)]; + +const lastPublished = (): unknown => native.setSecureRectangles.mock.lastCall; + + +function frames(count = 1): void { + act(() => { + jest.advanceTimersByTime(16 * count); + }); +} + +function wait(ms: number): void { + act(() => { + jest.advanceTimersByTime(ms); + }); +} + + +/** The first rectangle of the last publish on display 0, as [left, top, right, bottom]. */ +const firstPublished = (): number[] => (native.setSecureRectangles.mock.lastCall?.[1] as number[]).slice(0, 4); + + +describe('round 4: parked views', () => { + it('R1: a parked view whose clipper stops clipping is masked whole within a frame', () => { + const clipping = fabricHost('RCTView', rect(0, 0, 400, 120), { style: { overflow: 'hidden' } }, [self()]); + const fiber = selfIn(root([clipping])); + render(fiber); + frames(12); + wait(13); + expect(lastPublished()).toEqual([0, px(rect(10, 100, 200, 20))]); + + commitProps(ancestorOfType(fiber, 'RCTView'), { style: { overflow: 'visible' } }); + frames(); + + expect(firstPublished()).toEqual(px(selfRect)); + }); + + it('a parked view whose ScrollView becomes overflow: visible is masked whole within a frame', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 120), [self()])])); + render(fiber); + frames(12); + wait(13); + + commitProps(ancestorOfType(fiber, 'RCTScrollView'), { style: { overflow: 'visible' } }); + frames(); + + expect(firstPublished()).toEqual(px(selfRect)); + }); + + it('R2: one of 200 parked views moved into view by a layout shift is masked within a tick', () => { + const n = 200; + const rects: WindowRect[] = Array.from({ length: n }, () => rect(10, 500, 200, 50)); + const fibers = selvesIn(root([scrollView(rect(0, 0, 400, 120), Array.from({ length: n }, () => self()))])); + let testRenderer: ReactTestRenderer | undefined; + act(() => { + testRenderer = create( + <> + {fibers.map((_, index) => ( + + ))} + , + { + createNodeMock: (element: { props: { testID?: string } }) => { + const index = Number(element.props.testID); + return { + measureInWindow: (callback: Callback) => { + const at = rects[index] as WindowRect; + callback(at.x, at.y, at.width, at.height); + }, + __internalInstanceHandle: fibers[index], + }; + }, + unstable_isConcurrent: true, + } as Parameters[1], + ); + }); + mounted.push(testRenderer as ReactTestRenderer); + frames(12); + wait(300); + expect((lastPublished() as [number, number[]] | undefined)?.[1] ?? []).toEqual([]); + + // A sibling's layout moves one into the viewport; the content does not move. + rects[n - 1] = rect(10, 20, 200, 50); + let ms = 0; + while (ms < 2000 && ((lastPublished() as [number, number[]] | undefined)?.[1] ?? []).length === 0) { + wait(16); + ms += 16; + } + + expect(ms).toBeLessThanOrEqual(116); + }); + + it('holds on a clock that only goes forward: a wall-clock jump ends no hold early', () => { + const clipping = fabricHost('RCTView', rect(0, 0, 400, 800), { style: { overflow: 'hidden' } }, [self()]); + const fiber = selfIn(root([clipping])); + render(fiber); + moveTo(ancestorOfType(fiber, 'RCTView'), rect(0, 0, 400, 120)); + tick(); + expect(lastPublished()).toEqual([0, [...px(rect(10, 100, 200, 20)), ...px(selfRect)]]); + + jest.setSystemTime(Date.now() + 3_600_000); + tick(); + + expect(lastPublished()).toEqual([0, [...px(rect(10, 100, 200, 20)), ...px(selfRect)]]); + }); + + it('a publish that failed for a lane of parked views is published again on the next tick', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 120), [self()])])); + render(fiber); + // It moves, then parks holding where it was. + selfRect = rect(10, 90, 200, 50); + frames(12); + expect(lastPublished()).toEqual([0, [...px(rect(10, 90, 200, 30)), ...px(rect(10, 100, 200, 20))]]); + + // The tick that drops the held box fails to publish; nothing on the lane + // will change again. + native.setSecureRectangles.mockClear(); + native.setSecureRectangles.mockImplementationOnce(() => { + throw new Error('bridge down'); + }); + wait(SECURE_SHRINK_HOLD_MS + 300); + + // The failed publish, then the same again on the next tick. + expect(native.setSecureRectangles.mock.calls).toEqual([ + [0, px(rect(10, 90, 200, 30))], + [0, px(rect(10, 90, 200, 30))], + ]); + }); + +}); diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx index 140b174a..55e383fd 100644 --- a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx @@ -119,13 +119,16 @@ describe('', () => { tick(); expect(measureInWindow).toHaveBeenCalledTimes(before + 1); - // An ancestor scrolled: no layout event, but the region follows. + // An ancestor scrolled: no layout event, but the region follows at once, + // and the old one is held until SECURE_SHRINK_HOLD_MS has passed. measureInWindow.mockImplementation(measuresAt(10, 70, 30, 40)); tick(); - expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 140, 80, 220]); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 140, 80, 220, ...MEASURED]); tick(3); expect(measureInWindow).toHaveBeenCalledTimes(before + 5); + tick(3); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 140, 80, 220]); }); it('removes its rectangle on unmount', () => { @@ -202,7 +205,8 @@ describe('', () => { measureInWindow.mockImplementation(measuresAt(10, 70, 30, 40)); tick(); - expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 140, 80, 220]); + // The last good rectangle is held for a while after the region moved. + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 140, 80, 220, ...MEASURED]); }); // A measurement the registry rejects (a detached view can report NaN) is @@ -253,7 +257,7 @@ describe('', () => { layout(renderer); - expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [0, 0, 100, 100]); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [0, 0, 100, 100, ...MEASURED]); }); it("calls the caller's onLayout", () => { diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.visible.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.visible.test.tsx new file mode 100644 index 00000000..057ad334 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.visible.test.tsx @@ -0,0 +1,379 @@ +/** + * `` publishes only the part of itself that is on screen + * (bugsee/bugsee-react-native#86): cut to the ScrollViews and + * `overflow: 'hidden'` views around it, and withdrawn while an ancestor + * hides it. A region left where the view is not masks, and on Android drops + * the taps on, whatever is there instead; a region withdrawn or cut while + * the view shows leaks it. Every test checks one side of that, and how much + * measuring each re-measure costs. + */ +import { act } from 'react'; +import type { ReactElement } from 'react'; +import { create } from 'react-test-renderer'; +import type { ReactTestRenderer } from 'react-test-renderer'; +import { BugseeSecure } from '../BugseeSecure'; +import { SCREEN_HOST_TYPE } from '../ancestry'; +import { SECURE_SHRINK_HOLD_MS } from '../hold'; +import { MODAL_HOST_TYPE } from '../surface'; +import { native } from '../../__mocks__/native'; +import type { FiberLike, WindowRect } from '../../viewtree/fiber'; +import { + ancestorOfType, + commitProps, + fabricHost, + fakeRenderer, + moveTo, + rect, + root, + screenContainer, + scrollView, + self, + selfIn, + selvesIn, + view, +} from './fakeAncestors'; +import type { FakeRenderer } from './fakeAncestors'; + +jest.mock('react-native', () => ({ + View: 'View', + PixelRatio: { get: () => 2 }, + Platform: { OS: 'android' }, +})); +jest.mock('../../NativeBugsee', () => require('../../__mocks__/native').nativeMock); + +let renderer: FakeRenderer; +// Delegates, so the mock module (cached for the whole file) reaches each +// test's own fake renderer. +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + getPublicInstanceFromInternalInstanceHandle: (fiber: FiberLike) => + renderer.getPublicInstanceFromInternalInstanceHandle(fiber), +})); + +// See BugseeSecure.test.tsx: React Native's own test-environment flags. +(globalThis as { IS_REACT_NATIVE_TEST_ENVIRONMENT?: boolean }).IS_REACT_NATIVE_TEST_ENVIRONMENT = true; +(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + +type Callback = (x: number, y: number, width: number, height: number) => void; + +/** Where the secure view itself measures to; `moveTo` the self fiber's rect. */ +let selfRect: WindowRect; +let measureInWindow: jest.Mock; +let mounted: ReactTestRenderer[] = []; + +beforeEach(() => { + jest.useFakeTimers(); + native.reset(); + renderer = fakeRenderer(); + selfRect = rect(10, 100, 200, 50); + measureInWindow = jest.fn((callback: Callback) => callback(selfRect.x, selfRect.y, selfRect.width, selfRect.height)); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); +}); + +afterEach(() => { + act(() => mounted.forEach((mountedRenderer) => mountedRenderer.unmount())); + mounted = []; + expect(jest.getTimerCount()).toBe(0); + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +/** Mounts `element` with its View backed by `fiber`. */ +function render(fiber: FiberLike | undefined, element: ReactElement = ): ReactTestRenderer { + let testRenderer: ReactTestRenderer | undefined; + act(() => { + testRenderer = create(element, { + createNodeMock: () => ({ measureInWindow, ...(fiber === undefined ? {} : { __internalInstanceHandle: fiber }) }), + unstable_isConcurrent: true, + } as Parameters[1]); + }); + mounted.push(testRenderer as ReactTestRenderer); + return testRenderer as ReactTestRenderer; +} + +function tick(times = 1): void { + act(() => { + jest.advanceTimersByTime(100 * times); + }); +} + +/** Past SECURE_SHRINK_HOLD_MS (and its 100 ms window), with ticks and frames running. */ +function hold(): void { + act(() => { + jest.advanceTimersByTime(SECURE_SHRINK_HOLD_MS + 200); + }); +} + +/** One frame of the per-frame re-measure (its 16 ms timer fallback here). */ +function frame(): void { + act(() => { + jest.advanceTimersByTime(16); + }); +} + +/** A window rectangle in points as the registry publishes it, at PixelRatio 2. */ +const px = ({ x, y, width, height }: WindowRect): number[] => [2 * x, 2 * y, 2 * (x + width), 2 * (y + height)]; + +const lastPublished = (): unknown => native.setSecureRectangles.mock.lastCall; + +/** + * Nothing but empty sets reached the bridge, at most once: the registry + * sends only a change, and the lane may already be empty from an earlier + * test (its state outlives each test, as a JS runtime's does). + */ +function publishedOnlyNothing(): void { + const calls = native.setSecureRectangles.mock.calls; + expect(calls.filter(([, flat]) => (flat as unknown[]).length > 0)).toEqual([]); + expect(calls.length).toBeLessThanOrEqual(1); +} + +describe(' clipping', () => { + it('publishes only what a vertical ScrollView shows', () => { + render(selfIn(root([view([scrollView(rect(0, 0, 400, 130), [self()])])]))); + + expect(lastPublished()).toEqual([0, px(rect(10, 100, 200, 30))]); + }); + + it('publishes nothing once scrolled fully out, and the visible part once back', () => { + render(selfIn(root([scrollView(rect(0, 0, 400, 130), [self()])]))); + expect(lastPublished()).toEqual([0, px(rect(10, 100, 200, 30))]); + + // Scrolled out: what it covered is held for SECURE_SHRINK_HOLD_MS, then goes. + selfRect = rect(10, 140, 200, 50); + frame(); + expect(lastPublished()).toEqual([0, px(rect(10, 100, 200, 30))]); + hold(); + expect(lastPublished()).toEqual([0, []]); + + // Back: covered again at once. + selfRect = rect(10, 120, 200, 50); + tick(); + expect(lastPublished()).toEqual([0, px(rect(10, 120, 200, 10))]); + }); + + it('is cut by every ScrollView it is nested in', () => { + const horizontal = scrollView(rect(50, 0, 100, 800), [self()], 'AndroidHorizontalScrollView'); + render(selfIn(root([scrollView(rect(0, 0, 400, 130), [horizontal])]))); + + expect(lastPublished()).toEqual([0, px(rect(50, 100, 100, 30))]); + }); + + it("is cut by a view styled overflow: 'hidden'", () => { + render(selfIn(root([fabricHost('RCTView', rect(0, 110, 400, 800), { style: { overflow: 'hidden' } }, [self()])]))); + + expect(lastPublished()).toEqual([0, px(rect(10, 110, 200, 40))]); + }); + + it('follows a ScrollView that moves', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 800), [self()])])); + render(fiber); + expect(lastPublished()).toEqual([0, px(selfRect)]); + + moveTo(ancestorOfType(fiber, 'RCTScrollView'), rect(0, 0, 400, 125)); + tick(); + expect(lastPublished()).toEqual([0, [...px(rect(10, 100, 200, 25)), ...px(selfRect)]]); + + hold(); + expect(lastPublished()).toEqual([0, px(rect(10, 100, 200, 25))]); + }); + + it('measures a ScrollView shared by two views once a frame', () => { + const [first, second] = selvesIn(root([scrollView(rect(0, 0, 400, 130), [self(), self()])])); + let testRenderer: ReactTestRenderer | undefined; + act(() => { + testRenderer = create( + <> + + + , + { + createNodeMock: (element: { props: { testID?: string } }) => ({ + measureInWindow, + __internalInstanceHandle: element.props.testID === 'first' ? first : second, + }), + unstable_isConcurrent: true, + } as Parameters[1], + ); + }); + mounted.push(testRenderer as ReactTestRenderer); + const before = renderer.measures.length; + + // Both are clipped, so both are on the per-frame re-measure: the + // ScrollView's content (watched) and the ScrollView, once each. + frame(); + + expect(renderer.measures.length - before).toBe(2); + expect(lastPublished()).toEqual([0, [...px(rect(10, 100, 200, 30)), ...px(rect(10, 100, 200, 30))]]); + }); + + it('measures the view and its clipping ancestors once each a frame, and publishes once', () => { + render(selfIn(root([scrollView(rect(0, 0, 400, 130), [scrollView(rect(0, 0, 400, 800), [self()])])]))); + const selfBefore = measureInWindow.mock.calls.length; + const ancestorsBefore = renderer.measures.length; + native.setSecureRectangles.mockClear(); + + selfRect = rect(10, 90, 200, 50); + frame(); + + expect(measureInWindow.mock.calls.length - selfBefore).toBe(1); + // Two ScrollViews and the two content containers watched under them. + expect(renderer.measures.length - ancestorsBefore).toBe(4); + expect(native.setSecureRectangles.mock.calls).toEqual([ + [0, [...px(rect(10, 90, 200, 40)), ...px(rect(10, 100, 200, 30))]], + ]); + }); +}); + +describe(' while hidden', () => { + /** A secure view under an RNSScreen with `activityState`. */ + function underScreen(activityState: number): FiberLike { + const screen = fabricHost(SCREEN_HOST_TYPE, rect(0, 0, 400, 800), { activityState }, [view([self()])]); + return selfIn(root([screenContainer([screen])])); + } + + it('an inactive screen withdraws it once held long enough, and an active one brings it back', () => { + const fiber = underScreen(2); + render(fiber); + expect(lastPublished()).toEqual([0, px(selfRect)]); + + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 0 }); + tick(); + expect(lastPublished()).toEqual([0, px(selfRect)]); + hold(); + expect(lastPublished()).toEqual([0, []]); + + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 2 }); + frame(); + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('mounted hidden, it publishes nothing and measures nothing', () => { + render(underScreen(0)); + tick(5); + + expect(measureInWindow).not.toHaveBeenCalled(); + expect(renderer.measures).toEqual([]); + publishedOnlyNothing(); + }); + + it('measures nothing while hidden, and withdraws once', () => { + const screen = fabricHost(SCREEN_HOST_TYPE, null, { activityState: 2 }, [self()]); + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 800), [screenContainer([screen])])])); + render(fiber); + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 0 }); + measureInWindow.mockClear(); + renderer.measures = []; + native.setSecureRectangles.mockClear(); + + // A tick to see it hidden, then the hold. + tick(9); + + expect(measureInWindow).not.toHaveBeenCalled(); + expect(renderer.measures).toEqual([]); + // Held, then withdrawn; the registry sends only a change across the bridge. + expect(native.setSecureRectangles.mock.calls).toEqual([[0, []]]); + }); +}); + +describe(' ancestors cost and failures', () => { + it('with nothing that clips, a re-measure is one measurement', () => { + render(selfIn(root([view([view([self()])], { style: { overflow: 'visible' } })]))); + const before = measureInWindow.mock.calls.length; + + tick(4); + + expect(measureInWindow.mock.calls.length - before).toBe(4); + expect(renderer.measures).toEqual([]); + }); + + it('with no fiber at all, a re-measure is one measurement', () => { + render(undefined); + const before = measureInWindow.mock.calls.length; + + tick(3); + + expect(measureInWindow.mock.calls.length - before).toBe(3); + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('reads its ancestors once per mount, however often it measures', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 800), [self()])])); + const parent = fiber.return; + let reads = 0; + Object.defineProperty(fiber, 'return', { + get(): FiberLike | null { + reads += 1; + return parent; + }, + }); + const testRenderer = render(fiber); + // One walk for the surface, one for the ancestry. + expect(reads).toBe(2); + + tick(5); + expect(reads).toBe(2); + expect(renderer.lookups).toHaveLength(1); + + act(() => testRenderer.update()); + act(() => testRenderer.update()); + expect(reads).toBe(4); + }); + + it('an ancestor whose measurement throws leaves the whole view masked', () => { + render(selfIn(root([scrollView(rect(0, 0, 400, 130), [self()])]))); + renderer.getPublicInstanceFromInternalInstanceHandle = () => ({ + measureInWindow: () => { + throw new Error('detached'); + }, + }); + + // The instance is looked up once; a fresh mount looks it up again. + act(() => mounted.forEach((mountedRenderer) => mountedRenderer.unmount())); + mounted = []; + render(selfIn(root([scrollView(rect(0, 0, 400, 130), [self()])]))); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('an ancestor that never answers leaves the whole view masked', () => { + render(selfIn(root([scrollView(null, [self()])]))); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it("the view's own failed measurement keeps the last rectangle", () => { + render(selfIn(root([scrollView(rect(0, 0, 400, 130), [self()])]))); + native.setSecureRectangles.mockClear(); + + selfRect = rect(Number.NaN, 100, 200, 50); + tick(2); + + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + }); + + it('an ancestor that throws while its props are read hides nothing', () => { + const fiber = selfIn(root([fabricHost(SCREEN_HOST_TYPE, null, { activityState: 0 }, [view([self()])])])); + commitProps(ancestorOfType(fiber, 'RCTView'), { + get style(): never { + throw new Error('torn props'); + }, + }); + render(fiber); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + // A Modal's content is its own surface: nothing outside it clips or hides + // what is inside. + it('stops at the Modal it is in', () => { + const modal = { + ...fabricHost(MODAL_HOST_TYPE, rect(0, 0, 400, 800), {}, [view([self()])]), + stateNode: { tag: 56, rect: null, canonical: { currentProps: {} } }, + }; + const hiding = { style: { display: 'none', overflow: 'hidden' } }; + const outside = fabricHost('RCTView', rect(0, 0, 1, 1), hiding, [modal]); + render(selfIn(root([scrollView(rect(0, 0, 1, 1), [outside])]))); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, 56, px(selfRect)); + expect(renderer.measures).toEqual([]); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/ancestry.test.ts b/packages/react-native/src/secure/__tests__/ancestry.test.ts new file mode 100644 index 00000000..d0ebf0d3 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/ancestry.test.ts @@ -0,0 +1,1145 @@ +/** + * What the ancestors of a `` say about how much of it is on + * screen. The failures guarded against go both ways: a region left over + * something it no longer covers (a tab bar, another tab), and a region + * withdrawn or cut while the view is still showing. Every doubt resolves to + * the second never happening. + */ +import { FiberTag } from '../../viewtree/fiber'; +import type { FiberLike } from '../../viewtree/fiber'; +import { + MODAL_SCREEN_HOST_TYPE, + ROOT_KIND_HOST_TYPES, + SCREEN_CONTAINER_HOST_TYPES, + SCREEN_HOST_TYPE, + SCROLL_HOST_TYPES, + ancestryOfFiber, + ancestryOfInstance, + clipToAncestors, + clipWatchNodes, + isHidden, + measureFiber, + measurePass, + noAncestry, + screensHide, + styleValue, +} from '../ancestry'; +import type { Ancestry } from '../ancestry'; +import { + ancestorOfType, + commitProps, + composite, + fabricHost, + fakeRenderer, + moveTo, + offscreen, + pairWith, + rect, + root, + scrollView, + screenContainer, + self, + selfIn, + selvesIn, + view, +} from './fakeAncestors'; +import type { FakeRenderer } from './fakeAncestors'; + +const RENDERER_PROXY = 'react-native/Libraries/ReactNative/RendererProxy'; + +let renderer: FakeRenderer; + +beforeEach(() => { + renderer = fakeRenderer(); + // A fresh registry each test, so the lazy require sees this test's mock. + jest.resetModules(); + jest.doMock(RENDERER_PROXY, () => ({ + getPublicInstanceFromInternalInstanceHandle: (fiber: FiberLike) => + renderer.getPublicInstanceFromInternalInstanceHandle(fiber), + })); +}); + +afterEach(() => { + jest.dontMock(RENDERER_PROXY); +}); + +/** The ancestry of the `SELF` host in `spec`'s tree. */ +const ancestryIn = (spec: Parameters[0]): Ancestry => ancestryOfFiber(selfIn(spec)); + +const types = (ancestry: Ancestry): unknown[] => ancestry.hosts.map((host) => ancestry.path[host.index]!.type); + +/** A secure view under an RNSScreen with committed `activityState`, inside a wrapper with `wrapperProps`. */ +function underScreen(activityState: unknown, wrapperProps: Record = {}): FiberLike { + return selfIn( + root([ + screenContainer([ + composite([fabricHost(SCREEN_HOST_TYPE, null, { activityState }, [view([self()])])], wrapperProps), + ]), + ]), + ); +} + +/** An RNSScreen whose committed `activityState` is 0, over `children`. */ +const inactive = (children: Parameters[3]) => + screenContainer([fabricHost(SCREEN_HOST_TYPE, null, { activityState: 0 }, children)]); + +describe('ancestryOfFiber', () => { + it('keeps the whole path to the root, and the hosts on it nearest first', () => { + const fiber = selfIn(root([view([composite([scrollView(rect(0, 0, 1, 1), [self()])])])])); + const ancestry = ancestryOfFiber(fiber); + + expect(ancestry.path.map((entry) => entry.tag)).toEqual([ + FiberTag.HostComponent, + FiberTag.HostComponent, + FiberTag.FunctionComponent, + FiberTag.HostComponent, + FiberTag.HostRoot, + ]); + expect(ancestry.path[0]).toBe(fiber.return); + expect(types(ancestry)).toEqual(['RCTScrollContentView', 'RCTScrollView', 'RCTView']); + expect(ancestry.hosts.map((host) => [host.index, host.above])).toEqual([ + [0, 1], + [1, 3], + [3, 5], + ]); + expect(ancestry.root).toBe(ancestry.path[4]!.stateNode); + expect(ancestry.readable).toBe(false); + }); + + it('has no root when the path does not end at one', () => { + const top = { tag: FiberTag.HostComponent, type: 'RCTView', return: null, stateNode: { current: 1 } } as FiberLike; + const fiber = { tag: FiberTag.HostComponent, type: 'SELF', return: top } as FiberLike; + + expect(ancestryOfFiber(fiber).root).toBeNull(); + expect(ancestryOfFiber({ ...fiber, return: null }).root).toBeNull(); + }); + + it.each([...ROOT_KIND_HOST_TYPES].filter((type) => type !== MODAL_SCREEN_HOST_TYPE))( + 'leaves out %s and every host above it', + (type) => { + const boundary = fabricHost(type as string, rect(0, 0, 1, 1), {}, [view([self()])]); + const ancestry = ancestryIn(root([scrollView(null, [boundary])])); + + expect(types(ancestry)).toEqual(['RCTView']); + // The view below it ends at the boundary, not at the next host above. + expect(ancestry.hosts[0]!.above).toBe(1); + expect(ancestry.path).toHaveLength(5); + }, + ); + + it('keeps a modal screen as its last host, a boundary screen, and none above it', () => { + const modal = fabricHost(MODAL_SCREEN_HOST_TYPE, null, {}, [view([self()])]); + const ancestry = ancestryIn(root([scrollView(null, [composite([modal])])])); + + expect(types(ancestry)).toEqual(['RCTView', MODAL_SCREEN_HOST_TYPE]); + expect(ancestry.hosts[1]).toMatchObject({ index: 1, above: 3, screen: true, boundary: true, scrolls: false }); + expect(ancestry.hosts[0]).toMatchObject({ screen: false, boundary: false }); + }); + + it('knows the root-kind hosts of React Native and react-native-screens', () => { + expect([...ROOT_KIND_HOST_TYPES]).toEqual([ + 'RCTModalHostView', + 'RCTInputAccessoryView', + 'RNSFullWindowOverlay', + 'RNSModalScreen', + 'RNSSplitScreen', + 'RNSFormSheetHost', + ]); + }); + + it('knows the react-native-screens screen hosts', () => { + expect(SCREEN_HOST_TYPE).toBe('RNSScreen'); + expect(MODAL_SCREEN_HOST_TYPE).toBe('RNSModalScreen'); + }); + + it('marks the ScrollView hosts of both platforms, and the screens', () => { + expect([...SCROLL_HOST_TYPES]).toEqual(['RCTScrollView', 'AndroidHorizontalScrollView']); + const ancestry = ancestryIn( + root([ + fabricHost(SCREEN_HOST_TYPE, null, {}, [ + scrollView(null, [scrollView(null, [self()], 'AndroidHorizontalScrollView')]), + ]), + ]), + ); + + expect(ancestry.hosts.map((host) => [ancestry.path[host.index]!.type, host.scrolls, host.screen])).toEqual([ + ['RCTScrollContentView', false, false], + ['AndroidHorizontalScrollView', true, false], + ['RCTScrollContentView', false, false], + ['RCTScrollView', true, false], + [SCREEN_HOST_TYPE, false, true], + ]); + }); + + it('starts every host unread and unmeasured', () => { + const [host] = ancestryIn(root([view([self()])])).hosts; + + expect(host).toMatchObject({ clips: false, instance: undefined }); + expect(typeof host?.props).toBe('symbol'); + }); + + it('ends a cyclic chain', () => { + const loop = { tag: FiberTag.HostComponent, type: 'RCTView', stateNode: null } as FiberLike; + loop.return = loop; + const fiber = { tag: FiberTag.HostComponent, type: 'SELF', return: loop } as FiberLike; + const ancestry = ancestryOfFiber(fiber); + + expect(ancestry.path).toHaveLength(10_000); + expect(ancestry.hosts).toHaveLength(10_000); + expect(ancestry.hosts[9_999]!.above).toBe(10_000); + }); +}); + +describe('ancestryOfInstance', () => { + it("reads the instance's fiber", () => { + const fiber = selfIn(root([view([self()])])); + + expect(types(ancestryOfInstance({ __internalInstanceHandle: fiber }))).toEqual(['RCTView']); + }); + + it.each([ + ['no fiber', {}], + ['a null fiber', { __internalInstanceHandle: null }], + ['a fiber that is not an object', { __internalInstanceHandle: 7 }], + ])('has no ancestors with %s', (_, instance) => { + expect(ancestryOfInstance(instance)).toEqual(noAncestry()); + }); + + it('has no ancestors when the tree throws while it is read', () => { + const fiber = { tag: FiberTag.HostComponent, type: 'SELF' }; + Object.defineProperty(fiber, 'return', { + get(): never { + throw new Error('torn fiber'); + }, + }); + + expect(ancestryOfInstance({ __internalInstanceHandle: fiber })).toEqual(noAncestry()); + }); + + it('noAncestry hides nothing and clips nothing', () => { + const none = noAncestry(); + + expect(none).toEqual({ path: [], root: null, hosts: [], screens: [], readable: false }); + expect(isHidden(none)).toBe(false); + expect(clipToAncestors(rect(1, 2, 3, 4), none)).toEqual(rect(1, 2, 3, 4)); + expect(noAncestry()).not.toBe(none); + }); +}); + +describe('styleValue', () => { + it.each([ + ['an object', { overflow: 'hidden' }, 'hidden'], + ['an object without the key', { opacity: 1 }, undefined], + ['the last entry that sets it', [{ overflow: 'hidden' }, { overflow: 'visible' }], 'visible'], + ['an earlier entry when later ones do not set it', [{ overflow: 'hidden' }, { opacity: 1 }, null, false], 'hidden'], + ['nested arrays', [{ overflow: 'visible' }, [[{ overflow: 'hidden' }], undefined]], 'hidden'], + ['an empty array', [], undefined], + ['null', null, undefined], + ['a registered style id', 12, undefined], + ['a string', 'hidden', undefined], + ])('reads %s', (_, style, expected) => { + expect(styleValue(style, 'overflow')).toBe(expected); + }); +}); + +describe('isHidden', () => { + it('is false with nothing hiding, and reads the tree', () => { + const ancestry = ancestryIn(root([view([view([self()])])])); + + expect(isHidden(ancestry)).toBe(false); + expect(ancestry.readable).toBe(true); + }); + + it.each([ + [0, true], + [1, false], + [2, false], + [-1, false], + ['0', false], + [undefined, false], + ])('a screen with committed activityState %p hides: %p', (activityState, hidden) => { + expect(isHidden(ancestryOfFiber(underScreen(activityState)))).toBe(hidden); + }); + + describe('a modal screen', () => { + /** A secure view in an RNSModalScreen with `props`, in a clipping ScrollView under an inactive screen. */ + function inModal(props: Record, wrapperProps: Record = {}): Ancestry { + const modal = fabricHost(MODAL_SCREEN_HOST_TYPE, null, props, [view([self()])]); + const stack = screenContainer([composite([modal], wrapperProps)], 'RNSScreenStack'); + return ancestryIn(root([inactive([scrollView(null, [stack])])])); + } + + it.each([ + [0, true], + [2, false], + ])('with committed activityState %p hides: %p', (activityState, hidden) => { + expect(isHidden(inModal({ activityState }))).toBe(hidden); + }); + + it('driven by an Animated activityState hides nothing', () => { + expect(isHidden(inModal({ activityState: 0 }, { activityState: { __getValue: () => 0 } }))).toBe(false); + }); + + it('never clips, whatever its style', () => { + const ancestry = inModal({ activityState: 2, style: { overflow: 'hidden' } }); + + isHidden(ancestry); + + expect(ancestry.hosts.map((host) => host.clips)).toEqual([false, false]); + }); + }); + + it('activityState 0 on any other host hides nothing', () => { + expect(isHidden(ancestryIn(root([view([self()], { activityState: 0 })])))).toBe(false); + }); + + it('hides when a screen further up is inactive, and still reads every host', () => { + const fiber = selfIn( + root([inactive([view([self()], { style: { overflow: 'hidden' } })])]), + ); + const ancestry = ancestryOfFiber(fiber); + + expect(isHidden(ancestry)).toBe(true); + expect(ancestry.hosts.map((host) => host.clips)).toEqual([true, false, false]); + }); + + it('a hidden display or Offscreen hides nothing: the shadow tree measures it 0x0', () => { + expect(isHidden(ancestryIn(root([view([self()], { style: { display: 'none' } })])))).toBe(false); + expect(isHidden(ancestryIn(root([offscreen(0, [view([self()])])])))).toBe(false); + }); + + describe('an Animated activityState', () => { + it.each([ + ['an Animated node', { __getValue: () => 0 }], + ['any object', {}], + ])('on the wrapper, %s, hides nothing', (_, activityState) => { + expect(isHidden(ancestryOfFiber(underScreen(0, { activityState })))).toBe(false); + }); + + it.each([ + ['a number', 0], + ['null', null], + ['nothing', undefined], + ])('on the wrapper, %s, leaves the committed 0 hiding', (_, activityState) => { + expect(isHidden(ancestryOfFiber(underScreen(0, { activityState })))).toBe(true); + }); + + it('on a wrapper with no props, leaves it hiding', () => { + const fiber = underScreen(0); + const wrapper = fiber.return!.return!.return!; + wrapper.memoizedProps = null; + + expect(isHidden(ancestryOfFiber(fiber))).toBe(true); + }); + + it('is read every time, even when the screen host has not changed', () => { + const fiber = underScreen(0, { activityState: 0 }); + const ancestry = ancestryOfFiber(fiber); + expect(isHidden(ancestry)).toBe(true); + + fiber.return!.return!.return!.memoizedProps = { activityState: { __getValue: () => 0 } }; + + expect(isHidden(ancestry)).toBe(false); + }); + + it('above the next host up is not the screen’s', () => { + const screen = fabricHost(SCREEN_HOST_TYPE, null, { activityState: 0 }, [view([self()])]); + const fiber = selfIn(root([composite([screenContainer([screen])], { activityState: { __getValue: () => 0 } })])); + + expect(isHidden(ancestryOfFiber(fiber))).toBe(true); + }); + }); + + describe('reads the committed fiber of each pair', () => { + it('not a render in flight, nor the canonical props it wrote', () => { + const fiber = underScreen(2); + pairWith(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 0 }, true); + + expect(isHidden(ancestryOfFiber(fiber))).toBe(false); + }); + + it('the committed one when the stored chain runs through the other', () => { + const fiber = underScreen(0); + pairWith(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 2 }); + + expect(isHidden(ancestryOfFiber(fiber))).toBe(true); + }); + + it('the committed one when the stored fiber is the committed one', () => { + const fiber = underScreen(0); + const screen = ancestorOfType(fiber, SCREEN_HOST_TYPE); + const other = pairWith(screen, { activityState: 2 }); + // The chain from below runs through the committed fiber again. + for (let child = screen.child; child !== null; child = child.sibling) { + child.return = screen; + } + + expect(isHidden(ancestryOfFiber(fiber))).toBe(true); + expect(other.alternate).toBe(screen); + }); + + it('the root of either fiber of the root pair', () => { + const fiber = underScreen(0); + const ancestry = ancestryOfFiber(fiber); + const top = ancestry.path[ancestry.path.length - 1]!; + const otherRoot = { ...top, alternate: top } as FiberLike; + top.alternate = otherRoot; + (ancestry.root as { current: unknown }).current = otherRoot; + otherRoot.child = top.child; + + expect(isHidden(ancestry)).toBe(true); + }); + + it('resolves a shared prefix once per pass, by either fiber of a pair', () => { + const fibers = selvesIn(root([inactive([view([self()]), view([self()])])])); + const screen = ancestorOfType(fibers[0]!, SCREEN_HOST_TYPE); + const other = pairWith(screen, { activityState: 2 }); + // One view's chain runs through each fiber of the screen's pair. + fibers[1]!.return!.return = screen; + fibers[0]!.return!.return = other; + const [first, second] = fibers.map((fiber) => ancestryOfFiber(fiber)); + let childReads = 0; + const rootFiber = screen.return!; + const children = rootFiber.child; + Object.defineProperty(rootFiber, 'child', { + get(): FiberLike | null { + childReads += 1; + return children; + }, + }); + + measurePass(() => { + expect([isHidden(first!), isHidden(second!)]).toEqual([true, true]); + }); + expect(childReads).toBe(1); + + isHidden(first!); + isHidden(second!); + expect(childReads).toBe(3); + }); + }); + + describe('cannot be read: false, and nothing clipped', () => { + function clipsAfter(ancestry: Ancestry): boolean { + const hidden = isHidden(ancestry); + expect(ancestry.readable).toBe(false); + return hidden; + } + + it('with no root', () => { + expect(clipsAfter({ ...ancestryOfFiber(underScreen(0)), root: null })).toBe(false); + }); + + it('with no path', () => { + expect(clipsAfter(noAncestry())).toBe(false); + }); + + it('with a root whose current is not the path’s top', () => { + const ancestry = ancestryOfFiber(underScreen(0)); + (ancestry.root as { current: unknown }).current = { tag: FiberTag.HostRoot }; + + expect(clipsAfter(ancestry)).toBe(false); + }); + + it('with a root that has no current', () => { + const ancestry = ancestryOfFiber(underScreen(0)); + (ancestry.root as { current: unknown }).current = null; + // React's first root has a null alternate: not a match either. + ancestry.path[ancestry.path.length - 1]!.alternate = null; + + expect(clipsAfter(ancestry)).toBe(false); + }); + + it('with a root whose current is some other tree holding the same children', () => { + const ancestry = ancestryOfFiber(underScreen(0)); + const top = ancestry.path[ancestry.path.length - 1]!; + (ancestry.root as { current: unknown }).current = { ...top, alternate: null }; + + expect(clipsAfter(ancestry)).toBe(false); + }); + + it('with a stored fiber that is not in the committed tree', () => { + const fiber = underScreen(0); + const screen = ancestorOfType(fiber, SCREEN_HOST_TYPE); + const ancestry = ancestryOfFiber(fiber); + screen.child = null; + + expect(clipsAfter(ancestry)).toBe(false); + }); + + it('with a stored fiber further up that is not in the committed tree', () => { + const fiber = underScreen(0); + const ancestry = ancestryOfFiber(fiber); + ancestry.path[2]!.child = null; + + expect(clipsAfter(ancestry)).toBe(false); + }); + + it('with a stored fiber found among later siblings', () => { + const fiber = selfIn(root([view([]), view([]), inactive([self()])])); + + expect(isHidden(ancestryOfFiber(fiber))).toBe(true); + }); + + it('with props that throw while read, even after a nearer host read as clipping', () => { + const fiber = selfIn(root([inactive([scrollView(null, [self()])])])); + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { + get style(): never { + throw new Error('torn props'); + }, + }); + const ancestry = ancestryOfFiber(fiber); + + expect(clipsAfter(ancestry)).toBe(false); + expect(ancestry.hosts[1]!.clips).toBe(true); + }); + + it('until it can be read again', () => { + const ancestry = ancestryOfFiber(underScreen(0)); + const rootState = ancestry.root as { current: unknown }; + const committed = rootState.current; + rootState.current = null; + expect(clipsAfter(ancestry)).toBe(false); + + rootState.current = committed; + expect(isHidden(ancestry)).toBe(true); + expect(ancestry.readable).toBe(true); + }); + }); + + it('follows a commit that hides and then shows the view', () => { + const fiber = underScreen(2); + const ancestry = ancestryOfFiber(fiber); + const screen = ancestorOfType(fiber, SCREEN_HOST_TYPE); + + expect(isHidden(ancestry)).toBe(false); + commitProps(screen, { activityState: 0 }); + expect(isHidden(ancestry)).toBe(true); + commitProps(screen, { activityState: 2 }); + expect(isHidden(ancestry)).toBe(false); + }); + + it('re-reads a host only when its committed props change', () => { + let reads = 0; + const props = { + get style(): unknown { + reads += 1; + return {}; + }, + }; + const fiber = selfIn(root([view([self()])])); + commitProps(ancestorOfType(fiber, 'RCTView'), props); + const ancestry = ancestryOfFiber(fiber); + + isHidden(ancestry); + isHidden(ancestry); + expect(reads).toBe(1); + + commitProps(ancestorOfType(fiber, 'RCTView'), { ...props }); + isHidden(ancestry); + expect(reads).toBe(2); + }); + + it('records which hosts clip', () => { + const open = view([scrollView(null, [self()])], { style: { overflow: 'visible' } }); + const ancestry = ancestryIn(root([view([open], { style: { overflow: 'hidden' } })])); + + isHidden(ancestry); + + expect(ancestry.hosts.map((host) => [ancestry.path[host.index]!.type, host.clips])).toEqual([ + ['RCTScrollContentView', false], + ['RCTScrollView', true], + ['RCTView', false], + ['RCTView', true], + ]); + }); + + it.each([ + [{ overflow: 'visible' }, false], + [[{ overflow: 'hidden' }, { overflow: 'visible' }], false], + [{ overflow: 'hidden' }, true], + [{ overflow: 'scroll' }, true], + [{}, true], + [undefined, true], + ])('a ScrollView styled %p clips: %p', (style, clips) => { + const fiber = selfIn(root([scrollView(null, [self()])])); + commitProps(ancestorOfType(fiber, 'RCTScrollView'), { style }); + const ancestry = ancestryOfFiber(fiber); + + isHidden(ancestry); + + expect(ancestry.hosts[1]).toMatchObject({ scrolls: true, clips }); + }); + + it('a host with no committed props clips nothing and hides nothing', () => { + const fiber = underScreen(0); + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), null); + + expect(isHidden(ancestryOfFiber(fiber))).toBe(false); + }); +}); + +describe('clipToAncestors', () => { + /** The ancestry of `spec`'s SELF, with its clips read. */ + function readAncestry(spec: Parameters[0]): Ancestry { + const ancestry = ancestryIn(spec); + isHidden(ancestry); + return ancestry; + } + + const SELF_RECT = rect(10, 100, 200, 50); + + it('returns the rectangle itself, measuring nothing, when nothing clips', () => { + const ancestry = readAncestry(root([view([view([self()])])])); + + expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); + expect(clipToAncestors(rect(0, 0, 0, 0), ancestry)).toEqual(rect(0, 0, 0, 0)); + expect(renderer.measures).toEqual([]); + expect(renderer.lookups).toEqual([]); + }); + + it('returns the rectangle itself with no ancestors', () => { + expect(clipToAncestors(SELF_RECT, noAncestry())).toBe(SELF_RECT); + }); + + it('returns the rectangle itself, measuring nothing, when the last read was not complete', () => { + const ancestry = readAncestry(root([scrollView(rect(0, 0, 400, 110), [self()])])); + ancestry.readable = false; + + expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); + expect(renderer.measures).toEqual([]); + }); + + it('lets an ancestor answering 0/0/0/0 clip nothing, and the others still clip', () => { + const only = readAncestry(root([scrollView(rect(0, 0, 0, 0), [self()])])); + expect(clipToAncestors(SELF_RECT, only)).toBe(SELF_RECT); + + const nested = readAncestry(root([scrollView(rect(0, 0, 400, 120), [scrollView(rect(0, 0, 0, 0), [self()])])])); + expect(clipToAncestors(SELF_RECT, nested)).toEqual(rect(10, 100, 200, 20)); + }); + + it.each([ + ['x', rect(5, 0, 0, 0)], + ['y', rect(0, 5, 0, 0)], + ['width', rect(0, 0, 5, 0)], + ['height', rect(0, 0, 0, 5)], + ])('lets an ancestor with a non-zero %s and nothing else clip', (_, measured) => { + expect(clipToAncestors(SELF_RECT, readAncestry(root([scrollView(measured, [self()])])))).toBeNull(); + }); + + it('keeps an ancestor answering 0/0/0/0 for the pass', () => { + const ancestry = readAncestry(root([scrollView(rect(0, 0, 0, 0), [self()])])); + + measurePass(() => { + clipToAncestors(SELF_RECT, ancestry); + expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); + }); + + expect(renderer.measures).toHaveLength(1); + }); + + it('is the whole rectangle inside the viewport', () => { + expect(clipToAncestors(SELF_RECT, readAncestry(root([scrollView(rect(0, 0, 400, 800), [self()])])))).toEqual( + SELF_RECT, + ); + }); + + it.each([ + ['above', rect(0, 120, 400, 800), rect(10, 120, 200, 30)], + ['below', rect(0, 0, 400, 130), rect(10, 100, 200, 30)], + ['to the left', rect(60, 0, 400, 800), rect(60, 100, 150, 50)], + ['to the right', rect(0, 0, 110, 800), rect(10, 100, 100, 50)], + ['on every side', rect(20, 110, 100, 20), rect(20, 110, 100, 20)], + ])('cuts off what lies outside the viewport %s', (_, viewport, expected) => { + expect(clipToAncestors(SELF_RECT, readAncestry(root([scrollView(viewport, [self()])])))).toEqual(expected); + }); + + it.each([ + ['above', rect(0, 0, 400, 100)], + ['below', rect(0, 150, 400, 800)], + ['to the left', rect(0, 0, 10, 800)], + ['to the right', rect(210, 0, 400, 800)], + ['far away', rect(0, 1000, 400, 800)], + ])('is nothing when the viewport lies wholly %s', (_, viewport) => { + expect(clipToAncestors(SELF_RECT, readAncestry(root([scrollView(viewport, [self()])])))).toBeNull(); + }); + + it('is cut by every clipping ancestor: nested ScrollViews', () => { + const horizontal = scrollView(rect(50, 0, 400, 800), [self()], 'AndroidHorizontalScrollView'); + const ancestry = readAncestry(root([scrollView(rect(0, 0, 400, 130), [horizontal])])); + + expect(clipToAncestors(SELF_RECT, ancestry)).toEqual(rect(50, 100, 160, 30)); + expect(renderer.measures.map((fiber) => fiber.type)).toEqual(['AndroidHorizontalScrollView', 'RCTScrollView']); + }); + + it("is cut by a view styled overflow: 'hidden', and not by one that is not", () => { + const clipped = fabricHost('RCTView', rect(0, 0, 400, 120), { style: { overflow: 'hidden' } }, [self()]); + const open = fabricHost('RCTView', rect(0, 0, 400, 120), { style: { overflow: 'visible' } }, [self()]); + + expect(clipToAncestors(SELF_RECT, readAncestry(root([clipped])))).toEqual(rect(10, 100, 200, 20)); + expect(clipToAncestors(SELF_RECT, readAncestry(root([open])))).toEqual(SELF_RECT); + }); + + it('follows the ancestors as they move', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 800), [self()])])); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + expect(clipToAncestors(SELF_RECT, ancestry)).toEqual(SELF_RECT); + + moveTo(ancestorOfType(fiber, 'RCTScrollView'), rect(0, 0, 400, 110)); + + expect(clipToAncestors(SELF_RECT, ancestry)).toEqual(rect(10, 100, 200, 10)); + }); + + it('looks each clipping host up once, however often it measures', () => { + const ancestry = readAncestry(root([scrollView(rect(0, 0, 400, 800), [self()])])); + + clipToAncestors(SELF_RECT, ancestry); + clipToAncestors(SELF_RECT, ancestry); + + expect(renderer.lookups).toHaveLength(1); + expect(renderer.measures).toHaveLength(2); + }); + + it('uses the clips the last isHidden read', () => { + const fiber = selfIn(root([view([self()])])); + const ancestry = ancestryOfFiber(fiber); + moveTo(ancestorOfType(fiber, 'RCTView'), rect(0, 0, 400, 110)); + isHidden(ancestry); + commitProps(ancestorOfType(fiber, 'RCTView'), { style: { overflow: 'hidden' } }); + + expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); + isHidden(ancestry); + expect(clipToAncestors(SELF_RECT, ancestry)).toEqual(rect(10, 100, 200, 10)); + }); + + describe('in a measure pass', () => { + /** Two views under one ScrollView, each with its ancestry read. */ + function twoUnder(viewport: Parameters[0]): Ancestry[] { + return selvesIn(root([scrollView(viewport, [self(), self()])])).map((fiber) => { + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + return ancestry; + }); + } + + it('measures an ancestor shared by two views once', () => { + const [first, second] = twoUnder(rect(0, 0, 400, 120)); + const results: unknown[] = []; + + measurePass(() => { + results.push(clipToAncestors(SELF_RECT, first!), clipToAncestors(rect(0, 110, 100, 100), second!)); + }); + + expect(results).toEqual([rect(10, 100, 200, 20), rect(0, 110, 100, 10)]); + expect(renderer.measures).toHaveLength(1); + }); + + it('keeps an ancestor that did not answer for the pass, unclipped for both', () => { + const [first, second] = twoUnder(null); + const results: unknown[] = []; + + measurePass(() => { + results.push(clipToAncestors(SELF_RECT, first!), clipToAncestors(SELF_RECT, second!)); + }); + + expect(results).toEqual([SELF_RECT, SELF_RECT]); + expect(renderer.measures).toHaveLength(1); + }); + + it('measures afresh in the next pass and outside any pass', () => { + const [first] = twoUnder(rect(0, 0, 400, 120)); + measurePass(() => { + clipToAncestors(SELF_RECT, first!); + }); + measurePass(() => { + clipToAncestors(SELF_RECT, first!); + }); + clipToAncestors(SELF_RECT, first!); + clipToAncestors(SELF_RECT, first!); + + expect(renderer.measures).toHaveLength(4); + }); + + it('ends the pass when it throws', () => { + const [first] = twoUnder(rect(0, 0, 400, 120)); + const failure = new Error('measurer broke'); + + expect(() => + measurePass(() => { + clipToAncestors(SELF_RECT, first!); + throw failure; + }), + ).toThrow(failure); + clipToAncestors(SELF_RECT, first!); + + expect(renderer.measures).toHaveLength(2); + }); + }); + + describe('fails closed: the whole rectangle, unclipped', () => { + it('when a clipping ancestor never answers', () => { + const ancestry = readAncestry(root([scrollView(rect(0, 0, 400, 120), [scrollView(null, [self()])])])); + + expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); + }); + + it.each([ + ['x', rect(Number.NaN, 0, 400, 120)], + ['y', rect(0, Number.POSITIVE_INFINITY, 400, 120)], + ['width', rect(0, 0, Number.NaN, 120)], + ['height', rect(0, 0, 400, Number.NEGATIVE_INFINITY)], + ])('when a clipping ancestor answers a non-finite %s', (_, measured) => { + const ancestry = readAncestry(root([scrollView(rect(0, 0, 400, 110), [scrollView(measured, [self()])])])); + + expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); + }); + + it('when a clipping ancestor further out never answers, after a nearer one cut', () => { + const ancestry = readAncestry(root([scrollView(null, [scrollView(rect(0, 0, 400, 120), [self()])])])); + + expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); + }); + + it('when a clipping ancestor further out throws, after a nearer one cut', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 800), [scrollView(rect(0, 0, 400, 120), [self()])])])); + const ancestry = ancestryOfFiber(fiber); + const outer = ancestry.path[ancestry.hosts[3]!.index]!; + isHidden(ancestry); + const working = renderer.getPublicInstanceFromInternalInstanceHandle; + renderer.getPublicInstanceFromInternalInstanceHandle = (host) => { + if (host === outer) { + throw new Error('detached'); + } + return working(host); + }; + + expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); + }); + + it('when measuring a clipping ancestor throws', () => { + const ancestry = readAncestry(root([scrollView(rect(0, 0, 400, 120), [self()])])); + renderer.getPublicInstanceFromInternalInstanceHandle = () => ({ + measureInWindow: () => { + throw new Error('detached'); + }, + }); + + expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); + }); + + it('when the renderer cannot be loaded', () => { + const ancestry = readAncestry(root([scrollView(rect(0, 0, 400, 120), [self()])])); + jest.doMock(RENDERER_PROXY, () => { + throw new Error('no renderer'); + }); + + expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); + }); + + // A non-finite rectangle is the view's own failed measurement; it goes + // on as it is, for the registry to reject. + it.each([ + ['x', rect(Number.NaN, 100, 200, 50)], + ['y', rect(10, Number.NaN, 200, 50)], + ['width', rect(10, 100, Number.POSITIVE_INFINITY, 50)], + ['height', rect(10, 100, 200, Number.NaN)], + ])("when the view's own %s is not finite", (_, measured) => { + const ancestry = readAncestry(root([scrollView(rect(0, 1000, 400, 120), [self()])])); + + expect(clipToAncestors(measured, ancestry)).toBe(measured); + expect(renderer.measures).toEqual([]); + }); + }); +}); + +// Round 3 of the review of #87. +describe('round 3', () => { + it('a HostPortal ends the walk like a root-kind host', () => { + const portal = { tag: FiberTag.HostPortal, type: null, children: [view([self()])] }; + const ancestry = ancestryIn(root([inactive([scrollView(rect(0, 0, 1, 1), [portal])])])); + + expect(types(ancestry)).toEqual(['RCTView']); + expect(isHidden(ancestry)).toBe(false); + }); + + it('knows the containers that detach inactive screens', () => { + expect([...SCREEN_CONTAINER_HOST_TYPES]).toEqual([ + 'RNSScreenContainer', + 'RNSScreenNavigationContainer', + 'RNSScreenStack', + ]); + }); + + it.each([...SCREEN_CONTAINER_HOST_TYPES])('a screen 0 under %s hides', (type) => { + const screen = fabricHost(SCREEN_HOST_TYPE, null, { activityState: 0 }, [view([self()])]); + + expect(isHidden(ancestryIn(root([screenContainer([composite([screen])], type as string)])))).toBe(true); + }); + + it.each([ + ['a plain view', (children: Parameters[0]) => view(children)], + ['nothing at all', (children: Parameters[0]) => composite(children)], + ])('a screen 0 under %s hides nothing', (_, above) => { + const screen = fabricHost(SCREEN_HOST_TYPE, null, { activityState: 0 }, [view([self()])]); + + expect(isHidden(ancestryIn(root([above([screen])])))).toBe(false); + }); + + it('reads a host again after a read that threw', () => { + let throws = true; + const props = { + get style(): unknown { + if (throws) { + throw new Error('torn props'); + } + return { overflow: 'hidden' }; + }, + }; + const fiber = selfIn(root([view([self()])])); + commitProps(ancestorOfType(fiber, 'RCTView'), props); + const ancestry = ancestryOfFiber(fiber); + + expect(isHidden(ancestry)).toBe(false); + expect(ancestry.readable).toBe(false); + throws = false; + isHidden(ancestry); + + expect(ancestry.hosts[0]!.clips).toBe(true); + }); + + describe('screensHide', () => { + it('is false with no screen', () => { + expect(screensHide(ancestryIn(root([view([self()])])))).toBe(false); + }); + + it('answers as isHidden does for the screens', () => { + expect(screensHide(ancestryOfFiber(underScreen(0)))).toBe(true); + expect(screensHide(ancestryOfFiber(underScreen(2)))).toBe(false); + expect(screensHide(ancestryOfFiber(underScreen(0, { activityState: { __getValue: () => 0 } })))).toBe(false); + }); + + it('reads the committed fiber, not one in flight', () => { + const fiber = underScreen(2); + pairWith(ancestorOfType(fiber, SCREEN_HOST_TYPE), { activityState: 0 }, true); + + expect(screensHide(ancestryOfFiber(fiber))).toBe(false); + }); + + it('is false when the path does not resolve, or a read throws', () => { + const unresolved = ancestryOfFiber(underScreen(0)); + (unresolved.root as { current: unknown }).current = null; + expect(screensHide(unresolved)).toBe(false); + + const fiber = underScreen(0); + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { + get activityState(): never { + throw new Error('torn props'); + }, + }); + expect(screensHide(ancestryOfFiber(fiber))).toBe(false); + }); + + it('resolves only down to the nearest screen, not the view below it', () => { + const fiber = underScreen(0); + const ancestry = ancestryOfFiber(fiber); + // Below the screen, the committed tree no longer holds the view's parent. + ancestorOfType(fiber, SCREEN_HOST_TYPE).child = null; + + expect(screensHide(ancestry)).toBe(true); + expect(isHidden(ancestry)).toBe(false); + }); + + it('answers once a pass for every view under the same screen, by either fiber of it', () => { + const fibers = selvesIn(root([inactive([view([self()]), view([self()])])])); + const screen = ancestorOfType(fibers[0]!, SCREEN_HOST_TYPE); + const other = pairWith(screen, { activityState: 0 }); + fibers[0]!.return!.return = other; + fibers[1]!.return!.return = screen; + const [first, second] = fibers.map((fiber) => ancestryOfFiber(fiber)); + let reads = 0; + const container = screen.return!; + const children = container.child; + Object.defineProperty(container, 'child', { + get(): FiberLike | null { + reads += 1; + return children; + }, + }); + + measurePass(() => { + expect([screensHide(first!), screensHide(second!)]).toEqual([true, true]); + }); + expect(reads).toBe(1); + + screensHide(first!); + screensHide(second!); + expect(reads).toBe(3); + }); + }); + + describe('clipWatchNodes', () => { + it("is a ScrollView's content container, or the clipping view itself", () => { + const clipping = fabricHost('RCTView', null, { style: { overflow: 'hidden' } }, [scrollView(null, [self()])]); + const fiber = selfIn(root([clipping])); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + + expect(clipWatchNodes(ancestry).map((node) => node.type)).toEqual([ + 'RCTScrollContentView', + 'RCTScrollView', + 'RCTView', + ]); + expect(clipWatchNodes(ancestry)[0]).toBe(ancestorOfType(fiber, 'RCTScrollContentView')); + }); + + it('is the ScrollView itself when what is below it is not a host', () => { + const scroll = fabricHost('RCTScrollView', null, {}, [composite([self()])]); + const ancestry = ancestryIn(root([scroll])); + isHidden(ancestry); + + expect(clipWatchNodes(ancestry).map((node) => node.type)).toEqual(['RCTScrollView']); + }); + + it('is nothing when nothing clips, or the last read was not complete', () => { + const ancestry = ancestryIn(root([view([self()])])); + isHidden(ancestry); + expect(clipWatchNodes(ancestry)).toEqual([]); + + const unread = ancestryIn(root([scrollView(null, [self()])])); + expect(clipWatchNodes(unread)).toEqual([]); + }); + }); + + describe('measureFiber', () => { + it('looks a fiber’s instance up once, and measures once a pass', () => { + const fiber = ancestorOfType(selfIn(root([scrollView(rect(0, 0, 400, 120), [self()])])), 'RCTScrollView'); + + expect(measureFiber(fiber)).toEqual(rect(0, 0, 400, 120)); + measurePass(() => { + measureFiber(fiber); + measureFiber(fiber); + }); + + expect(renderer.lookups).toHaveLength(1); + expect(renderer.measures).toHaveLength(2); + }); + + it('answers 0/0/0/0 as null and silence as undefined', () => { + const zero = ancestorOfType(selfIn(root([scrollView(rect(0, 0, 0, 0), [self()])])), 'RCTScrollView'); + const silent = ancestorOfType(selfIn(root([scrollView(null, [self()])])), 'RCTScrollView'); + + expect(measureFiber(zero)).toBeNull(); + expect(measureFiber(silent)).toBeUndefined(); + }); + }); + + it('scans a long sibling list once a pass, not once per view in it', () => { + // 50 rows, each holding a secure view: the rows are siblings on the paths. + const fibers = selvesIn(root([view(Array.from({ length: 50 }, () => view([self()])))])); + const ancestries = fibers.map((fiber) => ancestryOfFiber(fiber)); + let reads = 0; + for (let row = fibers[0]!.return!.return!.child; row !== null; ) { + const next: FiberLike | null = row.sibling; + Object.defineProperty(row, 'sibling', { + get(): FiberLike | null { + reads += 1; + return next; + }, + }); + row = next; + } + + measurePass(() => ancestries.forEach((ancestry) => expect(isHidden(ancestry)).toBe(false))); + + expect(ancestries.every((ancestry) => ancestry.readable)).toBe(true); + expect(reads).toBe(50); + }); + +}); + +describe('round 3: edges', () => { + it('a stored pair neither of which is in the committed tree does not resolve', () => { + const fiber = underScreen(0); + const screen = ancestorOfType(fiber, SCREEN_HOST_TYPE); + pairWith(screen, { activityState: 0 }); + const ancestry = ancestryOfFiber(fiber); + (screen.return as FiberLike).child = null; + + expect(isHidden(ancestry)).toBe(false); + expect(ancestry.readable).toBe(false); + }); + + it('the container above a screen is not read as the screen’s wrapper', () => { + const screen = fabricHost(SCREEN_HOST_TYPE, null, { activityState: 0 }, [view([self()])]); + const container = fabricHost('RNSScreenContainer', null, { activityState: { __getValue: () => 0 } }, [screen]); + + expect(isHidden(ancestryIn(root([container])))).toBe(true); + }); + + it('hosts with no props at all are read, clip nothing and hide nothing', () => { + const fiber = selfIn(root([screenContainer([fabricHost(SCREEN_HOST_TYPE, null, {}, [view([self()])])])])); + commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), null); + commitProps(ancestorOfType(fiber, 'RCTView'), null); + const ancestry = ancestryOfFiber(fiber); + + expect(isHidden(ancestry)).toBe(false); + expect(ancestry.readable).toBe(true); + }); + + it('a screen with no host above it is read, and hides nothing', () => { + const ancestry = ancestryIn(root([fabricHost(SCREEN_HOST_TYPE, null, { activityState: 0 }, [view([self()])])])); + + expect(isHidden(ancestry)).toBe(false); + expect(ancestry.readable).toBe(true); + expect(screensHide(ancestry)).toBe(false); + }); + + it('any inactive screen hides, not only when every one is', () => { + const inner = fabricHost(SCREEN_HOST_TYPE, null, { activityState: 0 }, [view([self()])]); + const outer = fabricHost(SCREEN_HOST_TYPE, null, { activityState: 2 }, [screenContainer([inner])]); + const ancestry = ancestryIn(root([screenContainer([outer])])); + + expect(screensHide(ancestry)).toBe(true); + expect(isHidden(ancestry)).toBe(true); + }); + + it('screensHide reads the screen’s props once a pass for every view under it', () => { + const fibers = selvesIn(root([inactive([view([self()]), view([self()])])])); + const screen = ancestorOfType(fibers[0]!, SCREEN_HOST_TYPE); + let reads = 0; + const props = screen.memoizedProps; + Object.defineProperty(screen, 'memoizedProps', { + get(): unknown { + reads += 1; + return props; + }, + }); + const [first, second] = fibers.map((fiber) => ancestryOfFiber(fiber)); + + measurePass(() => { + screensHide(first!); + screensHide(second!); + }); + + expect(reads).toBe(1); + }); + + it('watches nothing once a read fails, whatever clipped before', () => { + const ancestry = ancestryIn(root([scrollView(null, [self()])])); + isHidden(ancestry); + expect(clipWatchNodes(ancestry)).toHaveLength(2); + + (ancestry.root as { current: unknown }).current = null; + isHidden(ancestry); + + expect(clipWatchNodes(ancestry)).toEqual([]); + }); + + it('watches a ScrollView itself when the view is its direct child', () => { + const ancestry = ancestryIn(root([fabricHost('RCTScrollView', null, {}, [self()])])); + isHidden(ancestry); + + expect(clipWatchNodes(ancestry).map((node) => node.type)).toEqual(['RCTScrollView']); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/fakeAncestors.ts b/packages/react-native/src/secure/__tests__/fakeAncestors.ts new file mode 100644 index 00000000..157bbb0c --- /dev/null +++ b/packages/react-native/src/secure/__tests__/fakeAncestors.ts @@ -0,0 +1,170 @@ +/** + * Fake fiber trees for the secure view's ancestry (`ancestry.ts`), built + * with the view tree's `buildFiberTree`. The tree built is the committed one: + * its root's `stateNode.current` is itself and its fibers' `memoizedProps` + * are committed props, also mirrored on `stateNode.canonical.currentProps` + * as Fabric keeps them. {@link pairWith} adds the other fiber of a pair (a + * stale or in-flight one) and routes the stored `.return` chain through it. + * `stateNode.rect` is what the host measures to in the window (`null`: it + * never answers). + */ +import { FiberTag } from '../../viewtree/fiber'; +import type { FiberLike, WindowRect } from '../../viewtree/fiber'; +import { buildFiberTree } from '../../viewtree/__tests__/fakeFibers'; +import type { FiberSpec } from '../../viewtree/__tests__/fakeFibers'; + +/** The host type of the secure view itself. */ +export const SELF = 'BugseeSecureView'; + +export const rect = (x: number, y: number, width: number, height: number): WindowRect => ({ x, y, width, height }); + +/** A Fabric host whose committed props are `props`, holding `memoizedProps` itself. */ +export function fabricHost( + type: string, + measured: WindowRect | null, + props: Record, + children: FiberSpec[], + memoizedProps: unknown = props, +): FiberSpec { + return { + tag: FiberTag.HostComponent, + type, + memoizedProps, + stateNode: { rect: measured, canonical: { currentProps: props } }, + children, + }; +} + +export const view = (children: FiberSpec[], props: Record = {}): FiberSpec => + fabricHost('RCTView', rect(0, 0, 400, 800), props, children); + +export const scrollView = (measured: WindowRect | null, children: FiberSpec[], type = 'RCTScrollView'): FiberSpec => + fabricHost(type, measured, {}, [fabricHost('RCTScrollContentView', rect(0, -5000, 400, 9000), {}, children)]); + +export const offscreen = (visibility: unknown, children: FiberSpec[]): FiberSpec => ({ + tag: FiberTag.Offscreen, + type: undefined, + memoizedState: null, + stateNode: { _visibility: visibility }, + children, +}); + +export const composite = (children: FiberSpec[], props: Record = {}): FiberSpec => ({ + tag: FiberTag.FunctionComponent, + type: () => null, + memoizedProps: props, + children, +}); + +/** A react-native-screens container, which detaches its inactive screens. */ +export const screenContainer = (children: FiberSpec[], type = 'RNSScreenContainer'): FiberSpec => + fabricHost(type, null, {}, children); + +export const self = (): FiberSpec => ({ tag: FiberTag.HostComponent, type: SELF, stateNode: { canonical: {} } }); + +export const root = (children: FiberSpec[]): FiberSpec => ({ tag: FiberTag.HostRoot, type: null, children }); + +/** Every `SELF` host in the tree `spec` builds, in no particular order. */ +export function selvesIn(spec: FiberSpec): FiberLike[] { + const found: FiberLike[] = []; + const top = buildFiberTree(spec); + if (top.tag === FiberTag.HostRoot && top.stateNode === null) { + top.stateNode = { current: top }; + } + const pending: FiberLike[] = [top]; + while (pending.length > 0) { + const fiber = pending.pop() as FiberLike; + if (fiber.type === SELF) { + found.push(fiber); + } + for (let child = fiber.child; child !== null; child = child.sibling) { + pending.push(child); + } + } + return found; +} + +/** The `SELF` host in the tree `spec` builds. */ +export function selfIn(spec: FiberSpec): FiberLike { + const [fiber] = selvesIn(spec); + if (fiber === undefined) { + throw new Error('no SELF host in the tree'); + } + return fiber; +} + +/** The nearest ancestor of `fiber` of type `type`, along its stored `.return` chain. */ +export function ancestorOfType(fiber: FiberLike, type: unknown): FiberLike { + for (let current = fiber.return; current !== null; current = current.return) { + if (current.type === type) { + return current; + } + } + throw new Error(`no ${String(type)} above the fiber`); +} + +/** Replaces a committed host's props, as a commit that changed them would. */ +export function commitProps(fiber: FiberLike, props: unknown): void { + fiber.memoizedProps = props; + (fiber.stateNode as { canonical: { currentProps: unknown } }).canonical.currentProps = props; +} + +/** + * Gives the committed `fiber` its other fiber, holding `props`, and points + * `fiber`'s children's `.return` at that other fiber, so a chain stored from + * below runs through it. `rendered`: it is a render in flight (or abandoned) + * rather than a stale leftover, and has written its props to the shared + * `canonical`, as `completeWork` does. + */ +export function pairWith(fiber: FiberLike, props: unknown, rendered = false): FiberLike { + const other: FiberLike = { ...fiber, memoizedProps: props, alternate: fiber }; + fiber.alternate = other; + for (let child = fiber.child; child !== null; child = child.sibling) { + child.return = other; + } + if (rendered) { + (fiber.stateNode as { canonical: { currentProps: unknown } }).canonical.currentProps = props; + } + return other; +} + +/** Moves a host to `measured`, as a scroll or layout would. */ +export function moveTo(fiber: FiberLike, measured: WindowRect | null): void { + (fiber.stateNode as { rect: WindowRect | null }).rect = measured; +} + +/** Counts what the fake renderer is asked, so a test can see measuring. */ +export interface FakeRenderer { + lookups: FiberLike[]; + measures: FiberLike[]; + getPublicInstanceFromInternalInstanceHandle: (fiber: FiberLike) => unknown; +} + +/** A RendererProxy stand-in: a host's public instance measures to its `stateNode.rect`. */ +export function fakeRenderer(): FakeRenderer { + const instances = new Map(); + const renderer: FakeRenderer = { + lookups: [], + measures: [], + getPublicInstanceFromInternalInstanceHandle: (fiber) => { + renderer.lookups.push(fiber); + // One public instance per host, as the renderer keeps it on canonical. + let instance = instances.get(fiber); + if (instance === undefined) { + instance = { + __nativeTag: (fiber.stateNode as { tag?: unknown } | null)?.tag, + measureInWindow: (callback: (x: number, y: number, width: number, height: number) => void) => { + renderer.measures.push(fiber); + const measured = (fiber.stateNode as { rect: WindowRect | null }).rect; + if (measured !== null) { + callback(measured.x, measured.y, measured.width, measured.height); + } + }, + }; + instances.set(fiber, instance); + } + return instance; + }, + }; + return renderer; +} diff --git a/packages/react-native/src/secure/__tests__/hold.test.ts b/packages/react-native/src/secure/__tests__/hold.test.ts new file mode 100644 index 00000000..c1f4dbd5 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/hold.test.ts @@ -0,0 +1,112 @@ +/** + * Coverage is added at once and removed late: what a view stopped covering + * stays published for SECURE_SHRINK_HOLD_MS, because native may still draw it + * there for a while after the shadow tree moved on. + */ +import { CoverageHold, SECURE_SHRINK_HOLD_MS } from '../hold'; +import type { WindowRect } from '../../viewtree/fiber'; + +const rect = (x: number, y: number, width: number, height: number): WindowRect => ({ x, y, width, height }); +const A = rect(0, 0, 10, 10); +const B = rect(20, 0, 10, 10); +const C = rect(40, 0, 10, 10); + +describe('CoverageHold', () => { + it('holds 500 ms', () => { + expect(SECURE_SHRINK_HOLD_MS).toBe(500); + }); + + it('publishes nothing, and holds nothing, before anything is covered', () => { + expect(new CoverageHold().holding()).toBe(false); + expect(new CoverageHold().update(null, 1_000)).toEqual([]); + }); + + it('publishes what is covered now, at once', () => { + const hold = new CoverageHold(); + + expect(hold.update(A, 1_000)).toEqual([A]); + expect(hold.holding()).toBe(false); + }); + + it('an unchanged rectangle holds nothing', () => { + const hold = new CoverageHold(); + hold.update(A, 1_000); + + expect(hold.update({ ...A }, 1_050)).toEqual([A]); + expect(hold.holding()).toBe(false); + }); + + it.each([ + ['x', rect(1, 0, 10, 10)], + ['y', rect(0, 1, 10, 10)], + ['width', rect(0, 0, 9, 10)], + ['height', rect(0, 0, 10, 9)], + ])('holds the old rectangle when its %s changes', (_, moved) => { + const hold = new CoverageHold(); + hold.update(A, 1_000); + + expect(hold.update(moved, 1_010)).toEqual([moved, A]); + expect(hold.holding()).toBe(true); + }); + + it('holds what it stopped covering for at least SECURE_SHRINK_HOLD_MS, then drops it', () => { + const hold = new CoverageHold(); + hold.update(A, 1_000); + // Left at 1050, in the 1000-1099 window: held until 1100 + 500. + hold.update(null, 1_050); + + expect(hold.publishable(1_550)).toEqual([A]); + expect(hold.publishable(1_599)).toEqual([A]); + expect(hold.publishable(1_600)).toEqual([]); + expect(hold.holding()).toBe(false); + }); + + it('collects what left within one window into one box', () => { + const hold = new CoverageHold(); + hold.update(A, 1_000); + hold.update(B, 1_010); + hold.update(C, 1_020); + + expect(hold.publishable(1_030)).toEqual([C, rect(0, 0, 30, 10)]); + }); + + it('keeps a box per window, each dropped on its own', () => { + const hold = new CoverageHold(); + hold.update(A, 1_000); + hold.update(B, 1_050); + hold.update(C, 1_150); + + expect(hold.publishable(1_160)).toEqual([C, A, B]); + expect(hold.publishable(1_600)).toEqual([C, B]); + expect(hold.publishable(1_700)).toEqual([C]); + }); + + it('grows a box on every side', () => { + const hold = new CoverageHold(); + hold.update(rect(10, 10, 10, 10), 1_000); + hold.update(rect(0, 0, 5, 5), 1_010); + hold.update(rect(30, 40, 5, 5), 1_020); + hold.update(null, 1_030); + + expect(hold.publishable(1_040)).toEqual([rect(0, 0, 35, 45)]); + }); + + it('hidden and shown again within the hold, it never stops covering', () => { + const hold = new CoverageHold(); + hold.update(A, 1_000); + + expect(hold.update(null, 1_100)).toEqual([A]); + expect(hold.update(A, 1_400)).toEqual([A, A]); + }); + + it('clear forgets everything at once', () => { + const hold = new CoverageHold(); + hold.update(A, 1_000); + hold.update(B, 1_010); + + hold.clear(); + + expect(hold.holding()).toBe(false); + expect(hold.publishable(1_020)).toEqual([]); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/measureLoop.test.ts b/packages/react-native/src/secure/__tests__/measureLoop.test.ts index f3d56eda..c246b6db 100644 --- a/packages/react-native/src/secure/__tests__/measureLoop.test.ts +++ b/packages/react-native/src/secure/__tests__/measureLoop.test.ts @@ -5,6 +5,22 @@ */ import type * as LoopModule from '../measureLoop'; +// The loop publishes each tick's and frame's changes together; what that +// does is the registry's, tested there. +const mockTogether: string[] = []; +const mockRetries: boolean[] = []; +jest.mock('../registry', () => ({ + publishTogether: (changes: () => void, retryFailed = false) => { + mockRetries.push(retryFailed); + mockTogether.push('start'); + try { + changes(); + } finally { + mockTogether.push('end'); + } + }, +})); + type Loop = typeof LoopModule; let loop: Loop; @@ -139,3 +155,461 @@ describe('the shared re-measure loop', () => { }); }); + +// While a view is withdrawn as hidden, or clipped, it re-measures before +// every frame, so it is masked again (or follows a scroll into view) within a +// frame rather than a 100 ms tick. +describe('the shared per-frame re-measure', () => { + const g = globalThis as { requestAnimationFrame?: unknown; cancelAnimationFrame?: unknown }; + let frames: Map void>; + let cancelled: number[]; + + /** Installs a fake requestAnimationFrame, or only one half of it. */ + function fakeFrames(halves: Array<'request' | 'cancel'> = ['request', 'cancel']): void { + frames = new Map(); + cancelled = []; + let next = 0; + if (halves.includes('request')) { + g.requestAnimationFrame = (callback: () => void) => { + next += 1; + frames.set(next, callback); + return next; + }; + } + if (halves.includes('cancel')) { + g.cancelAnimationFrame = (handle: number) => { + cancelled.push(handle); + frames.delete(handle); + }; + } + } + + /** Runs the one pending frame. */ + function runFrame(): void { + expect(frames.size).toBe(1); + const [handle, callback] = [...frames][0]!; + frames.delete(handle); + callback(); + } + + afterEach(() => { + delete g.requestAnimationFrame; + delete g.cancelAnimationFrame; + jest.dontMock('../ancestry'); + }); + + it('one frame callback serves every frame measurer, in one measure pass', () => { + fakeFrames(); + const passes: unknown[] = []; + jest.doMock('../ancestry', () => ({ + measurePass: (pass: () => void) => { + passes.push(pass); + pass(); + }, + })); + jest.resetModules(); + loop = require('../measureLoop'); + const first = jest.fn(); + const second = jest.fn(); + + loop.addFrameMeasurer(first); + loop.addFrameMeasurer(second); + expect(frames.size).toBe(1); + + runFrame(); + expect(passes).toHaveLength(1); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + // And again before the next frame, while any are left. + expect(frames.size).toBe(1); + }); + + it('stops requesting frames when the last frame measurer leaves', () => { + fakeFrames(); + const removeFirst = loop.addFrameMeasurer(jest.fn()); + const removeSecond = loop.addFrameMeasurer(jest.fn()); + + removeFirst(); + expect(cancelled).toEqual([]); + removeSecond(); + expect(cancelled).toEqual([1]); + expect(frames.size).toBe(0); + }); + + it('requests no next frame when the measurers left during the frame', () => { + fakeFrames(); + let remove: () => void = () => undefined; + remove = loop.addFrameMeasurer(() => remove()); + + runFrame(); + + expect(frames.size).toBe(0); + }); + + it('starts again for a measurer added after it stopped', () => { + fakeFrames(); + loop.addFrameMeasurer(jest.fn())(); + const measure = jest.fn(); + + loop.addFrameMeasurer(measure); + runFrame(); + + expect(measure).toHaveBeenCalledTimes(1); + }); + + it('one measurer that throws does not stop the rest', () => { + fakeFrames(); + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + const failure = new Error('detached'); + const later = jest.fn(); + loop.addFrameMeasurer(() => { + throw failure; + }); + loop.addFrameMeasurer(later); + + runFrame(); + + expect(later).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith('[Bugsee] a secure-region measurer threw', failure.name); + expect(frames.size).toBe(1); + }); + + it.each([ + ['no requestAnimationFrame', []], + ['only requestAnimationFrame', ['request']], + ['only cancelAnimationFrame', ['cancel']], + ] as Array<[string, Array<'request' | 'cancel'>]>)('falls back to a timer with %s', (_, halves) => { + fakeFrames(halves); + const measure = jest.fn(); + const remove = loop.addFrameMeasurer(measure); + + jest.advanceTimersByTime(loop.FRAME_MS - 1); + expect(measure).not.toHaveBeenCalled(); + jest.advanceTimersByTime(1); + expect(measure).toHaveBeenCalledTimes(1); + expect(frames.size).toBe(0); + + remove(); + expect(jest.getTimerCount()).toBe(0); + }); + + it('frames run beside the tick, each on its own', () => { + fakeFrames(); + const ticked = jest.fn(); + const framed = jest.fn(); + loop.addMeasurer(ticked); + loop.addFrameMeasurer(framed); + + runFrame(); + expect([ticked.mock.calls.length, framed.mock.calls.length]).toEqual([0, 1]); + jest.advanceTimersByTime(loop.SECURE_REMEASURE_MS); + expect([ticked.mock.calls.length, framed.mock.calls.length]).toEqual([1, 1]); + }); +}); + +// Round 3: what a static, clipped view costs is bounded. Its clipping nodes +// are watched (once a frame each, whatever the number of views under them), +// and the tick re-checks every parked view by measuring it alone. +describe('watched nodes and parked views', () => { + const g = globalThis as { requestAnimationFrame?: unknown; cancelAnimationFrame?: unknown }; + let where: Map; + let measured: unknown[]; + + beforeEach(() => { + where = new Map(); + measured = []; + jest.resetModules(); + jest.doMock('../ancestry', () => ({ + measurePass: (pass: () => void) => pass(), + measureFiber: (fiber: unknown) => { + measured.push(fiber); + const at = where.get(fiber); + if (at instanceof Error) { + throw at; + } + return at; + }, + })); + loop = require('../measureLoop'); + }); + + afterEach(() => { + jest.dontMock('../ancestry'); + delete g.requestAnimationFrame; + delete g.cancelAnimationFrame; + }); + + const node = (): never => ({}) as never; + const frame = (): void => { + jest.advanceTimersByTime(loop.FRAME_MS); + }; + + it('measures a watched node once a frame, however many watch it, and wakes them when it moves', () => { + const content = node(); + where.set(content, { x: 0, y: 0, width: 10, height: 10 }); + const first = jest.fn(); + const second = jest.fn(); + loop.watchNode(content, first); + loop.watchNode(content, second); + + frame(); + frame(); + // Once when first watched, then once a frame. + expect(measured).toEqual([content, content, content]); + expect(first).not.toHaveBeenCalled(); + + where.set(content, { x: 0, y: -5, width: 10, height: 10 }); + frame(); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['answers nothing', undefined], + ['answers 0/0/0/0', null], + ['throws', new Error('detached')], + ])('wakes when a node that answered goes on to %s, and when it comes back', (_, gone) => { + const content = node(); + where.set(content, { x: 0, y: 0, width: 10, height: 10 }); + const moved = jest.fn(); + loop.watchNode(content, moved); + frame(); + + where.set(content, gone); + frame(); + expect(moved).toHaveBeenCalledTimes(1); + frame(); + expect(moved).toHaveBeenCalledTimes(1); + + where.set(content, { x: 0, y: 0, width: 10, height: 10 }); + frame(); + expect(moved).toHaveBeenCalledTimes(2); + }); + + it('measures a node as soon as it is watched: a move before the next frame wakes', () => { + const content = node(); + where.set(content, { x: 0, y: 0, width: 10, height: 10 }); + const moved = jest.fn(); + loop.watchNode(content, moved); + expect(measured).toEqual([content]); + + where.set(content, { x: 0, y: 5, width: 10, height: 10 }); + frame(); + + expect(moved).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['its own props', (fiber: { memoizedProps: unknown }) => (fiber.memoizedProps = {})], + [ + 'its alternate’s props', + (fiber: { alternate: { memoizedProps: unknown } }) => (fiber.alternate.memoizedProps = {}), + ], + ])('wakes when %s change, though it has not moved', (_, change) => { + const content = { memoizedProps: {}, alternate: { memoizedProps: {} } } as never; + where.set(content, { x: 0, y: 0, width: 10, height: 10 }); + const moved = jest.fn(); + loop.watchNode(content, moved); + frame(); + expect(moved).not.toHaveBeenCalled(); + + change(content); + frame(); + expect(moved).toHaveBeenCalledTimes(1); + frame(); + expect(moved).toHaveBeenCalledTimes(1); + }); + + it('a node that throws reads as one that does not answer', () => { + const content = node(); + const moved = jest.fn(); + loop.watchNode(content, moved); + frame(); + + where.set(content, new Error('detached')); + frame(); + + expect(moved).not.toHaveBeenCalled(); + }); + + it('tells a node that stopped answering from one answering 0/0/0/0', () => { + const content = node(); + const moved = jest.fn(); + loop.watchNode(content, moved); + frame(); + + where.set(content, null); + frame(); + + expect(moved).toHaveBeenCalledTimes(1); + }); + + it('stops measuring a node, and frames, once nothing watches it', () => { + const content = node(); + const stopFirst = loop.watchNode(content, jest.fn()); + const stopSecond = loop.watchNode(content, jest.fn()); + frame(); + + stopFirst(); + frame(); + expect(measured).toHaveLength(3); + stopSecond(); + stopSecond(); + frame(); + expect(measured).toHaveLength(3); + expect(jest.getTimerCount()).toBe(0); + }); + + it('a stale unwatch leaves a node watched again under the same fiber alone', () => { + const content = node(); + const stop = loop.watchNode(content, jest.fn()); + stop(); + loop.watchNode(content, jest.fn()); + + stop(); + frame(); + + // Once each time it was first watched, then once a frame. + expect(measured).toEqual([content, content, content]); + }); + + it('keeps frames running while a node is watched with no frame measurer', () => { + const content = node(); + loop.watchNode(content, jest.fn()); + const removeMeasurer = loop.addFrameMeasurer(jest.fn()); + removeMeasurer(); + + frame(); + frame(); + + expect(measured).toHaveLength(3); + }); + + it('keeps frames running while a frame measurer is left and no node is watched', () => { + const measure = jest.fn(); + loop.addFrameMeasurer(measure); + loop.watchNode(node(), jest.fn())(); + + frame(); + frame(); + + expect(measure).toHaveBeenCalledTimes(2); + }); + + it('wakes measurers in the same frame, before running the frame measurers', () => { + const content = node(); + where.set(content, { x: 0, y: 0, width: 10, height: 10 }); + const measure = jest.fn(); + loop.watchNode(content, () => { + loop.addFrameMeasurer(measure); + }); + frame(); + where.set(content, { x: 0, y: 1, width: 10, height: 10 }); + + frame(); + + expect(measure).toHaveBeenCalledTimes(1); + }); + + it('keeps the frames going when a measurer throws and so does the warning', () => { + jest.spyOn(console, 'warn').mockImplementation(() => { + throw new Error('console gone'); + }); + const later = jest.fn(); + loop.addFrameMeasurer(() => { + throw new Error('detached'); + }); + loop.addFrameMeasurer(later); + + expect(() => frame()).toThrow('console gone'); + // The next frame was still requested, and runs (and throws) again. + expect(jest.getTimerCount()).toBe(1); + expect(() => frame()).toThrow('console gone'); + expect(jest.getTimerCount()).toBe(1); + }); + + it('re-checks every parked view every tick, however many', () => { + loop.addMeasurer(jest.fn()); + const checks = Array.from({ length: 200 }, () => jest.fn()); + checks.forEach((check) => loop.addParked(check)); + + jest.advanceTimersByTime(loop.SECURE_REMEASURE_MS); + + expect(checks.every((check) => check.mock.calls.length === 1)).toBe(true); + }); + + it('checks every parked view every tick when there are few, and none once removed', () => { + loop.addMeasurer(jest.fn()); + const first = jest.fn(); + const second = jest.fn(); + const removeFirst = loop.addParked(first); + loop.addParked(second); + + jest.advanceTimersByTime(loop.SECURE_REMEASURE_MS * 2); + expect([first.mock.calls.length, second.mock.calls.length]).toEqual([2, 2]); + + removeFirst(); + removeFirst(); + jest.advanceTimersByTime(loop.SECURE_REMEASURE_MS); + expect([first.mock.calls.length, second.mock.calls.length]).toEqual([2, 3]); + }); + + it('a parked check that throws does not stop the others', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + loop.addMeasurer(jest.fn()); + const failure = new Error('detached'); + loop.addParked(() => { + throw failure; + }); + const later = jest.fn(); + loop.addParked(later); + + jest.advanceTimersByTime(loop.SECURE_REMEASURE_MS); + + expect(later).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith('[Bugsee] a secure-region measurer threw', failure.name); + }); + + it('a parked view removed during a tick leaves the others checked', () => { + loop.addMeasurer(jest.fn()); + let removeFirst: () => void = () => undefined; + const first = jest.fn(() => removeFirst()); + const second = jest.fn(); + removeFirst = loop.addParked(first); + loop.addParked(second); + + jest.advanceTimersByTime(loop.SECURE_REMEASURE_MS * 2); + + expect([first.mock.calls.length, second.mock.calls.length]).toEqual([1, 2]); + }); + +}); + +describe('publishing together', () => { + beforeEach(() => { + mockTogether.length = 0; + mockRetries.length = 0; + }); + + it('retries failed publishes on the tick, not on frames', () => { + loop.addMeasurer(jest.fn()); + loop.addFrameMeasurer(jest.fn()); + + jest.advanceTimersByTime(loop.SECURE_REMEASURE_MS); + + // Six frames, then the tick. + expect(mockRetries).toEqual([false, false, false, false, false, false, true]); + }); + + it('runs a tick, and a frame, as one publish', () => { + const seen: string[] = []; + loop.addMeasurer(() => seen.push(`tick inside ${mockTogether.length % 2 === 1}`)); + loop.addFrameMeasurer(() => seen.push(`frame inside ${mockTogether.length % 2 === 1}`)); + + jest.advanceTimersByTime(loop.SECURE_REMEASURE_MS); + + expect(seen).toContain('tick inside true'); + expect(seen).toContain('frame inside true'); + expect(seen.some((entry) => entry.endsWith('false'))).toBe(false); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/registry.test.ts b/packages/react-native/src/secure/__tests__/registry.test.ts index 31a5024c..fb30caf4 100644 --- a/packages/react-native/src/secure/__tests__/registry.test.ts +++ b/packages/react-native/src/secure/__tests__/registry.test.ts @@ -345,3 +345,313 @@ describe('the secure-rectangle registry, per surface', () => { expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, MODAL, A_FLAT); }); }); + +// Every mounted republishes what it holds on every re-measure +// it has not moved. That must cost a comparison, not a re-flatten of the +// whole union, yet never skip a publish native still needs. +describe('the secure-rectangle registry, an owner that has not moved', () => { + let ratio: number; + let flatten: jest.SpyInstance; + + beforeEach(() => { + ratio = 2; + jest.resetModules(); + jest.doMock('react-native', () => ({ Platform: { OS: 'android' }, PixelRatio: { get: () => ratio } })); + native = require('../../__mocks__/native').native; + native.reset(); + registry = require('../registry'); + const rectangles = require('../rectangles') as { flattenSecureRectangles: () => number[] }; + flatten = jest.spyOn(rectangles, 'flattenSecureRectangles'); + }); + + // Back to the file's own mock, for whatever runs next. + afterEach(() => { + jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } })); + }); + + it('costs no flattening and no publish', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A, B]); + flatten.mockClear(); + native.setSecureRectangles.mockClear(); + + registry.setOwnerRectangles(component, 0, [{ ...A }, { ...B }]); + + expect(flatten).not.toHaveBeenCalled(); + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + }); + + it.each([ + ['x', { ...B, x: 101 }], + ['y', { ...B, y: 201 }], + ['width', { ...B, width: 11 }], + ['height', { ...B, height: 11 }], + ])('publishes a change of %s', (_, moved) => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A, B]); + + registry.setOwnerRectangles(component, 0, [A, moved]); + + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [ + 20, 40, 80, 120, + 2 * moved.x, 2 * moved.y, 2 * (moved.x + moved.width), 2 * (moved.y + moved.height), + ]); + }); + + it('publishes one rectangle more or fewer', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A]); + + registry.setOwnerRectangles(component, 0, [A, B]); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 40, 80, 120, 200, 400, 220, 420]); + + registry.setOwnerRectangles(component, 0, [A]); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 40, 80, 120]); + }); + + // The lanes it moves to are settled already, by other owners. + it('moves with an unchanged rectangle to another display or surface', () => { + const component = {}; + registry.setOwnerRectangles('manual:2', 2, []); + registry.setOwnerRectangles({}, 2, [], 42); + registry.setOwnerRectangles(component, 0, [A]); + + registry.setOwnerRectangles(component, 2, [A]); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, []); + expect(native.setSecureRectangles).toHaveBeenCalledWith(2, [20, 40, 80, 120]); + + registry.setOwnerRectangles(component, 2, [A], 42); + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(2, 42, [20, 40, 80, 120]); + }); + + it('republishes at a new pixel ratio', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A]); + + ratio = 3; + registry.setOwnerRectangles(component, 0, [A]); + + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [30, 60, 120, 180]); + }); + + it('skips again once the lane is flattened at the new ratio, even when nothing crossed', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, []); + ratio = 3; + registry.setOwnerRectangles(component, 0, []); + flatten.mockClear(); + + registry.setOwnerRectangles(component, 0, []); + + expect(flatten).not.toHaveBeenCalled(); + }); + + // Another owner's failed publish left native without its region. An + // owner that has not moved must not skip the retry, nor the other owner + // its own. + it('does not skip on a lane whose last publish failed', () => { + const first = {}; + const second = {}; + registry.setOwnerRectangles(first, 0, [A]); + native.setSecureRectangles.mockImplementationOnce(() => { + throw new Error('bridge down'); + }); + expect(() => registry.setOwnerRectangles(second, 0, [B])).toThrow('bridge down'); + + registry.setOwnerRectangles(second, 0, [B]); + + // The failed call, then the retry: not skipped. + expect(native.setSecureRectangles).toHaveBeenCalledTimes(3); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 40, 80, 120, 200, 400, 220, 420]); + }); + + it('skips nothing on another lane because one lane is settled', () => { + const component = {}; + registry.setOwnerRectangles(component, 0, [A], 42); + registry.setOwnerRectangles('manual:0', 0, [B]); + native.setSecureRectanglesOnSurface.mockImplementationOnce(() => { + throw new Error('bridge down'); + }); + expect(() => registry.setOwnerRectangles(component, 0, [B], 42)).toThrow('bridge down'); + + registry.setOwnerRectangles(component, 0, [B], 42); + + expect(native.setSecureRectanglesOnSurface).toHaveBeenCalledTimes(3); + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, 42, [200, 400, 220, 420]); + }); +}); + +// Every instance re-measured in one tick or frame changes its lane: they are +// published together at the end, once per lane, not once per change. +describe('the secure-rectangle registry, publishing together', () => { + beforeEach(() => { + jest.resetModules(); + jest.doMock('react-native', () => ({ Platform: { OS: 'ios' } })); + native = require('../../__mocks__/native').native; + native.reset(); + registry = require('../registry'); + }); + + it('publishes each changed lane once, at the end', () => { + const [first, second] = [{}, {}]; + registry.publishTogether(() => { + registry.setOwnerRectangles(first, 0, [A]); + registry.setOwnerRectangles(second, 0, [B]); + registry.setOwnerRectangles('manual:2', 2, [A]); + registry.setOwnerRectangles({}, 0, [B], 42); + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + }); + + expect(native.setSecureRectangles.mock.calls).toEqual([ + [0, [...A_FLAT, ...B_FLAT]], + [2, A_FLAT], + ]); + expect(native.setSecureRectanglesOnSurface.mock.calls).toEqual([[0, 42, B_FLAT]]); + }); + + it('publishes the lane an owner left as well as the one it moved to', () => { + const owner = {}; + registry.setOwnerRectangles(owner, 0, [A]); + + registry.publishTogether(() => registry.setOwnerRectangles(owner, 2, [A])); + + expect(native.setSecureRectangles.mock.calls.slice(1)).toEqual([ + [2, A_FLAT], + [0, []], + ]); + }); + + it('inside another, simply runs the changes', () => { + registry.publishTogether(() => { + registry.publishTogether(() => registry.setOwnerRectangles({}, 0, [A])); + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + }); + + expect(native.setSecureRectangles).toHaveBeenCalledTimes(1); + }); + + it('publishes even when the changes throw, and publishes at once after', () => { + expect(() => + registry.publishTogether(() => { + registry.setOwnerRectangles({}, 0, [A]); + throw new Error('measurer broke'); + }), + ).toThrow('measurer broke'); + expect(native.setSecureRectangles).toHaveBeenCalledWith(0, A_FLAT); + + registry.setOwnerRectangles({}, 0, [B]); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [...A_FLAT, ...B_FLAT]); + }); + + it('logs a lane whose publish threw, publishes the rest, and retries it on its next change', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + native.setSecureRectangles.mockImplementationOnce(() => { + throw new Error('bridge down'); + }); + const owner = {}; + registry.publishTogether(() => { + registry.setOwnerRectangles(owner, 0, [A]); + registry.setOwnerRectangles({}, 2, [B]); + }); + + expect(warn).toHaveBeenCalledWith('[Bugsee] could not publish secure rectangles', 'Error'); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(2, B_FLAT); + + // Unchanged, but its lane never reached the bridge: not skipped. + registry.setOwnerRectangles(owner, 0, [A]); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, A_FLAT); + warn.mockRestore(); + }); + + it('publishes a lane that failed again on the next call that retries, though nothing on it changed', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + native.setSecureRectanglesOnSurface.mockImplementationOnce(() => { + throw new Error('bridge down'); + }); + registry.publishTogether(() => registry.setOwnerRectangles({}, 0, [A], 42)); + expect(native.setSecureRectanglesOnSurface).toHaveBeenCalledTimes(1); + + // A frame does not retry it; a tick does. + registry.publishTogether(() => undefined); + expect(native.setSecureRectanglesOnSurface).toHaveBeenCalledTimes(1); + registry.publishTogether(() => undefined, true); + expect(native.setSecureRectanglesOnSurface).toHaveBeenCalledTimes(2); + expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, 42, A_FLAT); + + // Once it went through, it is not published again. + registry.publishTogether(() => undefined, true); + expect(native.setSecureRectanglesOnSurface).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); + + it('keeps a lane that keeps failing for each retry, and warns once until it publishes', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + native.setSecureRectangles.mockImplementation(() => { + throw new Error('bridge down'); + }); + registry.publishTogether(() => registry.setOwnerRectangles({}, 0, [A])); + registry.publishTogether(() => undefined, true); + registry.publishTogether(() => undefined, true); + + expect(native.setSecureRectangles).toHaveBeenCalledTimes(3); + expect(warn).toHaveBeenCalledTimes(1); + + // It goes through, then fails again: a new outage, a new warning. + native.setSecureRectangles.mockImplementation(() => undefined); + registry.publishTogether(() => undefined, true); + native.setSecureRectangles.mockImplementation(() => { + throw new Error('bridge down'); + }); + registry.publishTogether(() => registry.setOwnerRectangles({}, 0, [B])); + + expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); + + it('warns about a lane newly failing even while another is still failing', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + native.setSecureRectangles.mockImplementation(() => { + throw new Error('bridge down'); + }); + registry.publishTogether(() => registry.setOwnerRectangles({}, 0, [A])); + registry.publishTogether(() => registry.setOwnerRectangles({}, 2, [A])); + + expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); + + it('warns about a surface newly failing on a display with one already failing', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + native.setSecureRectangles.mockImplementation(() => { + throw new Error('bridge down'); + }); + const owner = {}; + registry.publishTogether(() => registry.setOwnerRectangles(owner, 0, [A])); + expect(warn).toHaveBeenCalledTimes(1); + + // It moves to a Modal's surface: that lane fails too, with the main one. + native.setSecureRectanglesOnSurface.mockImplementation(() => { + throw new Error('bridge down'); + }); + registry.publishTogether(() => registry.setOwnerRectangles(owner, 0, [A], 42)); + + expect(warn).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); + + it('a lane that failed and was published by a later change is not published again by a retry', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + native.setSecureRectangles.mockImplementationOnce(() => { + throw new Error('bridge down'); + }); + const owner = {}; + registry.publishTogether(() => registry.setOwnerRectangles(owner, 0, [A])); + registry.publishTogether(() => registry.setOwnerRectangles(owner, 0, [B])); + expect(native.setSecureRectangles).toHaveBeenCalledTimes(2); + + registry.publishTogether(() => undefined, true); + + expect(native.setSecureRectangles).toHaveBeenCalledTimes(2); + warn.mockRestore(); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/tracker.test.ts b/packages/react-native/src/secure/__tests__/tracker.test.ts new file mode 100644 index 00000000..11175ca7 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/tracker.test.ts @@ -0,0 +1,510 @@ +/** + * One ``'s state machine, driven directly: which loop it is + * on, when it is measured and what it publishes. Everything it talks to is + * a recording stand-in, so each transition is checked on its own. + */ +import type { WindowRect } from '../../viewtree/fiber'; + +interface Loop { + tick: Array<() => void>; + frame: Array<() => void>; + parked: Array<() => void>; + watched: Array<{ node: unknown; moved: () => void }>; + removed: string[]; +} + +const mockLoop: Loop = { tick: [], frame: [], parked: [], watched: [], removed: [] }; +const mockState = { + hidden: false, + screensHide: true, + clipTo: null as ((rect: WindowRect) => WindowRect | null) | null, + nodes: [] as unknown[], + published: [] as unknown[][], + cleared: 0, +}; + +/** Registers `fn` in `list`, and records its removal under `name`. */ +function mockRegister(list: Array<() => void>, name: string, fn: () => void): () => void { + list.push(fn); + return () => { + const index = list.indexOf(fn); + if (index >= 0) { + list.splice(index, 1); + } + mockLoop.removed.push(name); + }; +} + +jest.mock('../measureLoop', () => ({ + addMeasurer: (fn: () => void) => mockRegister(mockLoop.tick, 'tick', fn), + addFrameMeasurer: (fn: () => void) => mockRegister(mockLoop.frame, 'frame', fn), + addParked: (fn: () => void) => mockRegister(mockLoop.parked, 'parked', fn), + watchNode: (node: unknown, moved: () => void) => { + const entry = { node, moved }; + mockLoop.watched.push(entry); + return () => { + mockLoop.watched.splice(mockLoop.watched.indexOf(entry), 1); + mockLoop.removed.push('watch'); + }; + }, +})); +jest.mock('../ancestry', () => ({ + ancestryOfInstance: () => ({ readable: true }), + isHidden: () => mockState.hidden, + screensHide: () => mockState.screensHide, + clipToAncestors: (rect: WindowRect) => (mockState.clipTo === null ? rect : mockState.clipTo(rect)), + clipWatchNodes: () => mockState.nodes, +})); +jest.mock('../surface', () => ({ surfaceOfInstance: () => 0 })); +jest.mock('../registry', () => ({ + setOwnerRectangles: (_owner: unknown, _display: number, rectangles: unknown[]) => { + if (rectangles.some((rectangle) => !Number.isFinite((rectangle as WindowRect).x))) { + throw new RangeError('not finite'); + } + mockState.published.push(rectangles); + }, + clearOwner: () => { + mockState.cleared += 1; + }, +})); + +import { STEADY_FRAMES, SecureTracker, monotonicNow } from '../tracker'; + +const rect = (x: number, y: number, width: number, height: number): WindowRect => ({ x, y, width, height }); + +let self: WindowRect; +let measures: number; +let pending: Array<() => void> | null; +let tracker: SecureTracker; +let warn: jest.SpyInstance; + +const view = { + measureInWindow: (callback: (x: number, y: number, width: number, height: number) => void) => { + measures += 1; + const answer = () => callback(self.x, self.y, self.width, self.height); + if (pending === null) { + answer(); + } else { + pending.push(answer); + } + }, +}; + +beforeEach(() => { + jest.useFakeTimers(); + Object.assign(mockLoop, { tick: [], frame: [], parked: [], watched: [], removed: [] }); + Object.assign(mockState, { hidden: false, screensHide: false, clipTo: null, nodes: [], published: [], cleared: 0 }); + self = rect(0, 100, 100, 50); + measures = 0; + pending = null; + warn = jest.spyOn(console, 'warn').mockImplementation(() => undefined); + tracker = new SecureTracker(() => view, {}); +}); + +afterEach(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +const last = (): unknown[] | undefined => mockState.published[mockState.published.length - 1]; +const runFrames = (count = 1): void => { + for (let index = 0; index < count; index += 1) { + [...mockLoop.frame].forEach((fn) => fn()); + } +}; +const runTick = (): void => [...mockLoop.tick].forEach((fn) => fn()); +const runParked = (): void => [...mockLoop.parked].forEach((fn) => fn()); + +/** Cuts every rectangle to y < 120. */ +const cutAt120 = (r: WindowRect): WindowRect | null => + r.y >= 120 ? null : { ...r, height: Math.min(r.height, 120 - r.y) }; + +describe('SecureTracker', () => { + it('starts on the tick, measured, wholly visible', () => { + tracker.start(); + + expect(mockLoop.tick).toHaveLength(1); + expect(mockLoop.frame).toHaveLength(0); + expect(last()).toEqual([self]); + runTick(); + expect(measures).toBe(2); + }); + + it('stops clean: off every loop, unwatched, cleared, and its old coverage forgotten', () => { + mockState.clipTo = cutAt120; + mockState.nodes = ['content']; + tracker.start(); + self = rect(0, 90, 100, 50); + runFrames(); + + tracker.stop(); + + expect([mockLoop.tick, mockLoop.frame, mockLoop.watched]).toEqual([[], [], []]); + expect(mockState.cleared).toBe(1); + + // Started again, what it covered before is not held. + mockState.clipTo = null; + tracker.start(); + expect(last()).toEqual([self]); + }); + + it('stops from parked off the parked turns too', () => { + mockState.clipTo = cutAt120; + tracker.start(); + runFrames(STEADY_FRAMES + 1); + expect(mockLoop.parked).toHaveLength(1); + + tracker.stop(); + + expect(mockLoop.parked).toEqual([]); + }); + + it('stops without having started', () => { + expect(() => tracker.stop()).not.toThrow(); + }); + + it('measures nothing once stopped, even an answer already on its way', () => { + tracker.start(); + pending = []; + runTick(); + tracker.stop(); + const published = mockState.published.length; + + pending.forEach((answer) => answer()); + tracker.measure(); + + expect(mockState.published).toHaveLength(published); + }); + + it('measures nothing without a view', () => { + const empty = new SecureTracker(() => null, {}); + empty.start(); + + expect(measures).toBe(0); + expect(mockState.published).toEqual([]); + empty.stop(); + }); + + it.each([ + ['x', rect(Number.NaN, 100, 100, 50)], + ['y', rect(0, Number.NaN, 100, 50)], + ['width', rect(0, 100, Number.NaN, 50)], + ['height', rect(0, 100, 100, Number.POSITIVE_INFINITY)], + ])('keeps the last rectangle, and warns once, on a non-finite %s', (_, bad) => { + tracker.start(); + self = bad; + runTick(); + runTick(); + + expect(mockState.published).toEqual([[rect(0, 100, 100, 50)]]); + expect(warn).toHaveBeenCalledTimes(1); + expect(warn).toHaveBeenCalledWith('[Bugsee] BugseeSecure could not measure', 'RangeError'); + }); + + describe('clipped', () => { + beforeEach(() => { + mockState.clipTo = cutAt120; + mockState.nodes = ['content']; + }); + + it('goes on the per-frame re-measure and watches its clipping nodes', () => { + tracker.start(); + + expect(mockLoop.frame).toHaveLength(1); + expect(mockLoop.watched.map((entry) => entry.node)).toEqual(['content']); + runTick(); + expect(measures).toBe(1); + }); + + it('stays per-frame while it moves, however long', () => { + tracker.start(); + for (let frame = 0; frame < STEADY_FRAMES * 2; frame += 1) { + self = rect(0, 100 - frame, 100, 50); + runFrames(); + } + + expect(mockLoop.frame).toHaveLength(1); + expect(mockLoop.parked).toHaveLength(0); + }); + + it('parks after STEADY_FRAMES frames still, and measures nothing per frame then', () => { + tracker.start(); + runFrames(STEADY_FRAMES - 1); + expect(mockLoop.parked).toHaveLength(0); + runFrames(); + + // Still on frames, for its screens alone. + expect(mockLoop.frame).toHaveLength(1); + expect(mockLoop.parked).toHaveLength(1); + expect(mockLoop.watched).toHaveLength(1); + const before = measures; + runFrames(3); + expect(measures).toBe(before); + }); + + it('a parked turn measures the view alone, and nothing more while it has not moved', () => { + tracker.start(); + runFrames(STEADY_FRAMES); + const published = mockState.published.length; + const before = measures; + + runParked(); + + expect(measures - before).toBe(1); + expect(mockState.published).toHaveLength(published); + expect(mockLoop.parked).toHaveLength(1); + }); + + it('a parked turn that finds it moved measures it, and puts it back per-frame', () => { + tracker.start(); + runFrames(STEADY_FRAMES); + self = rect(0, 90, 100, 50); + + runParked(); + + expect(last()![0]).toEqual(rect(0, 90, 100, 30)); + expect(mockLoop.frame).toHaveLength(1); + expect(mockLoop.parked).toHaveLength(0); + }); + + it('parked, the frame its screens hide it, it is measured and goes hidden', () => { + tracker.start(); + runFrames(STEADY_FRAMES); + expect(mockLoop.parked).toHaveLength(1); + mockState.hidden = true; + mockState.screensHide = true; + + runFrames(); + + expect(mockLoop.parked).toHaveLength(0); + expect(mockLoop.frame).toHaveLength(1); + expect(mockLoop.watched).toEqual([]); + const before = measures; + runFrames(3); + expect(measures).toBe(before); + }); + + it('a layout that finds it where it was leaves it parked', () => { + tracker.start(); + runFrames(STEADY_FRAMES); + + tracker.measure(); + + expect(mockLoop.parked).toHaveLength(1); + const before = measures; + runFrames(3); + expect(measures).toBe(before); + }); + + it('a watched node that moves puts it back per-frame, with a fresh count', () => { + tracker.start(); + runFrames(STEADY_FRAMES); + + mockLoop.watched[0]!.moved(); + expect(mockLoop.frame).toHaveLength(1); + runFrames(STEADY_FRAMES - 1); + + expect(mockLoop.frame).toHaveLength(1); + }); + + it('a tick publishes nothing for it while per-frame, even with coverage held', () => { + tracker.start(); + self = rect(0, 90, 100, 50); + runFrames(); + const published = mockState.published.length; + + runTick(); + + expect(mockState.published).toHaveLength(published); + }); + + it('woken after layouts that found it still, it still needs STEADY_FRAMES still frames', () => { + tracker.start(); + runFrames(STEADY_FRAMES); + for (let layout = 0; layout < STEADY_FRAMES; layout += 1) { + tracker.measure(); + } + + mockLoop.watched[0]!.moved(); + runFrames(STEADY_FRAMES - 1); + + expect(mockLoop.frame).toHaveLength(1); + }); + + it('renews its watch when one of two nodes changes', () => { + mockState.nodes = ['a', 'b']; + tracker.start(); + mockState.nodes = ['a', 'c']; + + runFrames(); + + expect(mockLoop.watched.map((entry) => entry.node)).toEqual(['a', 'c']); + }); + + it('stays registered for frames when it goes from per-frame to hidden', () => { + tracker.start(); + mockState.hidden = true; + + runFrames(); + + expect(mockLoop.frame).toHaveLength(1); + expect(mockLoop.removed).not.toContain('frame'); + }); + + it('a tick does nothing for it while per-frame, or parked with nothing held', () => { + tracker.start(); + let published = mockState.published.length; + runTick(); + expect(mockState.published).toHaveLength(published); + runFrames(STEADY_FRAMES); + published = mockState.published.length; + const before = measures; + runTick(); + + expect(measures).toBe(before); + expect(mockState.published).toHaveLength(published); + }); + + it('a tick drops what a parked view held, once the hold is over', () => { + tracker.start(); + self = rect(0, 90, 100, 50); + runFrames(STEADY_FRAMES + 1); + expect(last()).toHaveLength(2); + + jest.advanceTimersByTime(700); + runTick(); + + expect(last()).toEqual([rect(0, 90, 100, 30)]); + }); + + it('watches only what clips now: unwatched once wholly visible', () => { + tracker.start(); + mockState.clipTo = null; + runFrames(); + + expect(mockLoop.watched).toEqual([]); + }); + + it('keeps its watch when the nodes are the same, and renews it when they change', () => { + tracker.start(); + runFrames(2); + expect(mockLoop.removed.filter((name) => name === 'watch')).toHaveLength(0); + + mockState.nodes = ['other']; + runFrames(); + expect(mockLoop.watched.map((entry) => entry.node)).toEqual(['other']); + + mockState.nodes = ['other', 'more']; + runFrames(); + expect(mockLoop.watched.map((entry) => entry.node)).toEqual(['other', 'more']); + }); + + it('a view clipped where it was wholly visible goes per-frame, not parked', () => { + mockState.clipTo = null; + tracker.start(); + runTick(); + // It grows, and what is visible of it stays exactly what was. + self = rect(0, 100, 100, 80); + mockState.clipTo = (r) => ({ ...r, height: 50 }); + + runTick(); + + expect(mockLoop.frame).toHaveLength(1); + expect(mockLoop.parked).toHaveLength(0); + }); + }); + + describe('hidden', () => { + beforeEach(() => { + mockState.screensHide = true; + }); + + it('holds what it covered, then withdraws, measuring nothing meanwhile', () => { + tracker.start(); + mockState.hidden = true; + runTick(); + expect(last()).toEqual([self]); + expect(mockLoop.frame).toHaveLength(1); + const before = measures; + + jest.advanceTimersByTime(700); + runFrames(); + + expect(last()).toEqual([]); + expect(measures).toBe(before); + }); + + it('publishes nothing per frame once nothing is held', () => { + tracker.start(); + mockState.hidden = true; + runTick(); + jest.advanceTimersByTime(700); + runFrames(); + const published = mockState.published.length; + + runFrames(3); + + expect(mockState.published).toHaveLength(published); + }); + + it('is measured the frame its screens stop hiding it, then stays per-frame until still', () => { + tracker.start(); + mockState.hidden = true; + runTick(); + mockState.hidden = false; + mockState.screensHide = false; + const before = measures; + + runFrames(); + + expect(measures - before).toBe(1); + expect(mockLoop.frame).toHaveLength(1); + runFrames(STEADY_FRAMES); + expect(mockLoop.frame).toHaveLength(0); + }); + + it('drops its watch while hidden', () => { + mockState.clipTo = cutAt120; + mockState.nodes = ['content']; + tracker.start(); + mockState.hidden = true; + + runFrames(); + + expect(mockLoop.watched).toEqual([]); + }); + }); + + it('wholly visible and still, a tick measures it; moving, it stays on the tick', () => { + tracker.start(); + self = rect(0, 50, 100, 50); + runTick(); + + expect(mockLoop.frame).toHaveLength(0); + expect(measures).toBe(2); + }); +}); + +describe('monotonicNow', () => { + const g = globalThis as { performance?: unknown }; + const real = g.performance; + + afterEach(() => { + g.performance = real; + }); + + it('reads performance.now, not the wall clock', () => { + g.performance = { now: () => 42 }; + + expect(monotonicNow()).toBe(42); + }); + + it.each([ + ['no performance', undefined], + ['a performance without now', {}], + ])('falls back to Date.now with %s', (_, performance) => { + g.performance = performance; + + expect(monotonicNow()).toBe(Date.now()); + }); +}); diff --git a/packages/react-native/src/secure/ancestry.ts b/packages/react-native/src/secure/ancestry.ts new file mode 100644 index 00000000..1f18205f --- /dev/null +++ b/packages/react-native/src/secure/ancestry.ts @@ -0,0 +1,505 @@ +import { FiberTag } from '../viewtree/fiber'; +import type { FiberLike, WindowRect } from '../viewtree/fiber'; + +/** + * What lies between a `` and its surface's root, read off the + * fiber tree once per mount, and what that says each tick about how much of + * the view is really on screen. + * + * Fabric `measureInWindow` is shadow-tree layout: it ignores every ancestor's + * clipping. So a region scrolled out of its ScrollView would still be + * published. This module finds the ancestors that clip the view, so the + * component can intersect its rectangle with them, and the one kind of + * ancestor that hides a view without touching its layout: an inactive + * react-native-screens screen, which stays mounted with its layout intact. + * + * Everything else that hides a view already shows in its layout, so it is + * left to the shadow tree: a `display: 'none'` subtree, and the content of a + * hidden Offscreen (``, Suspense), which Fabric hides by cloning + * its hosts with `display: 'none'` (`cloneHiddenInstance`), both measure + * 0x0, which publishes nothing. A frozen react-native-screens screen is + * hidden Offscreen content that stays drawn (`RNSScreen` nulls `display` in + * its view config for exactly that), and it keeps its layout, so it stays + * masked. + * + * Every rule leans towards masking: a view is hidden only on positive + * committed evidence, and an ancestor that cannot be read or measured clips + * nothing. + * + * Committed props come from the CURRENT fiber of each ancestor. A fiber and + * its alternate swap roles on every update, a stored `.return` chain can run + * through either, and both a work-in-progress fiber's `memoizedProps` and a + * Fabric host's `canonical.currentProps` are written during render, before + * (and whether or not) that render commits. The fiber root's `current` is + * the committed tree, and its `.child`/`.sibling` links are the committed + * links, so each tick resolves the stored path top-down from it, taking at + * every level whichever of the stored fiber and its alternate is a child of + * the current parent. A path that does not resolve is unknown: nothing is + * hidden and nothing clipped. + */ + +/** + * Host types whose own bounds clip their content, unless styled + * `overflow: 'visible'` (iOS: `clipsToBounds = getClipsContentToBounds()`): a + * ScrollView's viewport (`ScrollViewNativeComponent`, vertical on both + * platforms and horizontal on iOS, and + * `AndroidHorizontalScrollViewNativeComponent`). Their content containers + * scroll with the content and clip nothing. + */ +export const SCROLL_HOST_TYPES: ReadonlySet = new Set(['RCTScrollView', 'AndroidHorizontalScrollView']); + +/** + * Host types whose shadow node is `RootNodeKind`: `measureInWindow` stops + * there (`LayoutableShadowNode::computeRelativeLayoutMetrics`) and natively + * their content is drawn outside the hosts above, so nothing above one + * clips or hides what is inside, and the host itself clips nothing (only + * {@link MODAL_SCREEN_HOST_TYPE} can hide it). React Native's `` and + * `` (the JS names are their `paperComponentName`s), + * and react-native-screens' `FullWindowOverlay`, modal screen and split + * screen, plus its gamma form sheet host. A `HostPortal` fiber ends the walk + * the same way: its children are drawn in another container. + */ +export const ROOT_KIND_HOST_TYPES: ReadonlySet = new Set([ + 'RCTModalHostView', + 'RCTInputAccessoryView', + 'RNSFullWindowOverlay', + 'RNSModalScreen', + 'RNSSplitScreen', + // Not RootNodeKind, but its content is presented in a sheet of its own. + 'RNSFormSheetHost', +]); + +/** react-native-screens' screen host (`ScreenNativeComponent`). */ +export const SCREEN_HOST_TYPE = 'RNSScreen'; + +/** + * react-native-screens' iOS modal screen host (`ModalScreenNativeComponent`), + * which takes `activityState` like a screen. It is also root-kind, so it ends + * the walk: it can hide what is inside it, but never clips it. + */ +export const MODAL_SCREEN_HOST_TYPE = 'RNSModalScreen'; + +/** + * The react-native-screens containers whose screens `activityState` 0 really + * hides (detaches): a screen hides the view only when the nearest host above + * it is one of these. Anywhere else the prop proves nothing. + */ +export const SCREEN_CONTAINER_HOST_TYPES: ReadonlySet = new Set([ + 'RNSScreenContainer', + 'RNSScreenNavigationContainer', + 'RNSScreenStack', +]); + +/** `activityState` of an inactive react-native-screens screen. */ +const INACTIVE_SCREEN = 0; + +/** What a host's `props` holds before its first read: equal to no props object. */ +const UNREAD: unique symbol = Symbol('unread'); + +/** A cycle in a malformed tree must not spin forever. */ +const MAX_ANCESTORS = 10_000; + +type MeasureInWindow = (callback: (x: number, y: number, width: number, height: number) => void) => void; + +interface HostAncestor { + /** Where the host is in `Ancestry.path`. */ + readonly index: number; + /** Where the next host above it is in `Ancestry.path` (its length when there is none). */ + above: number; + readonly scrolls: boolean; + readonly screen: boolean; + /** A root-kind host: it ends the walk and clips nothing. */ + readonly boundary: boolean; + /** The committed props object `clips` was last read from. */ + props: unknown; + clips: boolean; + /** The host's public instance, looked up the first time it clips. */ + instance: { measureInWindow: MeasureInWindow } | undefined; +} + +export interface Ancestry { + /** Every fiber above the view, nearest first, up to and including the tree's root. */ + readonly path: readonly FiberLike[]; + /** The fiber root (`HostRoot.stateNode`), or `null` when the path has none. */ + readonly root: { current?: unknown } | null; + /** The hosts below the nearest root-kind host, nearest first. */ + readonly hosts: readonly HostAncestor[]; + /** The screens among `hosts`. */ + readonly screens: readonly HostAncestor[]; + /** Whether the last {@link isHidden} read everything; `false` clips nothing. */ + readable: boolean; +} + +/** Nothing above the view can be read: it is never hidden and never clipped. */ +export function noAncestry(): Ancestry { + return { path: [], root: null, hosts: [], screens: [], readable: false }; +} + +/** + * The ancestors of the host `fiber`, up to the tree's root. Only the hosts + * below the nearest root-kind host can clip or hide it. `fiber` itself is + * not included. + */ +export function ancestryOfFiber(fiber: FiberLike): Ancestry { + const path: FiberLike[] = []; + const hosts: HostAncestor[] = []; + let bounded = false; + let current = fiber.return; + for (let steps = 0; current != null && steps < MAX_ANCESTORS; steps += 1) { + bounded ||= current.tag === FiberTag.HostPortal; + if (current.tag === FiberTag.HostComponent) { + const below = hosts[hosts.length - 1]; + if (below !== undefined && below.above === MAX_ANCESTORS) { + below.above = path.length; + } + const boundary = ROOT_KIND_HOST_TYPES.has(current.type); + if (!bounded && (!boundary || current.type === MODAL_SCREEN_HOST_TYPE)) { + hosts.push({ + index: path.length, + above: MAX_ANCESTORS, + scrolls: SCROLL_HOST_TYPES.has(current.type), + screen: current.type === SCREEN_HOST_TYPE || current.type === MODAL_SCREEN_HOST_TYPE, + boundary, + props: UNREAD, + clips: false, + instance: undefined, + }); + } + bounded ||= boundary; + } + path.push(current); + current = current.return; + } + const last = hosts[hosts.length - 1]; + if (last !== undefined && last.above === MAX_ANCESTORS) { + last.above = path.length; + } + const top = path[path.length - 1]; + const root = top?.tag === FiberTag.HostRoot ? (top.stateNode as { current?: unknown } | null) : null; + return { path, root, hosts, screens: hosts.filter((host) => host.screen), readable: false }; +} + +/** + * The ancestry of a mounted host's public instance (Fabric's carries its + * fiber as `__internalInstanceHandle`). {@link noAncestry} when there is no + * fiber or the tree cannot be read. + */ +export function ancestryOfInstance(instance: object): Ancestry { + try { + // No fiber (or not an object) has no `.return`, or throws on reading it. + const fiber = (instance as { __internalInstanceHandle?: unknown }).__internalInstanceHandle; + return ancestryOfFiber(fiber as FiberLike); + } catch { + return noAncestry(); + } +} + +/** + * Each current parent's children, collected in the running pass the first + * time any view looks among them, so views that are siblings in a long list + * scan that list once a pass rather than once each. + */ +let passChildren: Map> | undefined; + +/** The child of `parent` (a current fiber) that is `fiber` or its alternate, if any. */ +function currentChild(parent: FiberLike, fiber: FiberLike): FiberLike | undefined { + let children = passChildren?.get(parent); + if (children === undefined) { + children = new Set(); + for (let child = parent.child; child != null; child = child.sibling) { + children.add(child); + } + passChildren?.set(parent, children); + } + // No child is null or undefined, so a missing alternate is never found. + const alternate = fiber.alternate as FiberLike; + return children.has(fiber) ? fiber : children.has(alternate) ? alternate : undefined; +} + +/** + * The current fiber of every fiber in `ancestry.path` from `downTo` up, in + * the same order (entries below `downTo` are left as the root), or + * `undefined` when the path does not resolve against the committed tree. + * Reads fibers only, which React never makes throw. + * + * Nothing is kept from one pass to the next: React keeps no commit count, and + * the root's `current` alternates between the same two fibers, so a cached + * level cannot be told apart from one two commits stale. + */ +function resolve(ancestry: Ancestry, downTo = 0): FiberLike[] | undefined { + const { path, root } = ancestry; + // A root exists only on a path that ends at one. + const top = path[path.length - 1] as FiberLike; + const rootCurrent = root?.current as FiberLike | null | undefined; + if (rootCurrent == null || (rootCurrent !== top && rootCurrent !== top.alternate)) { + return undefined; + } + // The root's entry stays; every other is replaced below, or the path does + // not resolve. + const resolved = path.map(() => rootCurrent); + for (let index = path.length - 2; index >= downTo; index -= 1) { + const current = currentChild(resolved[index + 1] as FiberLike, path[index] as FiberLike); + if (current === undefined) { + return undefined; + } + resolved[index] = current; + } + return resolved; +} + +/** + * `key` of a style as React Native flattens it: the last value set wins, + * through nested arrays; falsy entries are skipped. + */ +export function styleValue(style: unknown, key: string): unknown { + if (Array.isArray(style)) { + return style.reduceRight( + (found, entry) => (found !== undefined ? found : styleValue(entry, key)), + undefined, + ); + } + if (style !== null && typeof style === 'object') { + return (style as Record)[key]; + } + return undefined; +} + +/** + * An `activityState` set on a fiber between a screen host and the host + * above it (the `Animated.createAnimatedComponent` wrapper react-native-screens + * renders the host through) that is not a plain number: an Animated node, + * whose value native drives without React committing it. + */ +function animatedActivity(resolved: readonly FiberLike[], from: number, to: number): boolean { + for (let index = from; index < to; index += 1) { + const value = ((resolved[index] as FiberLike).memoizedProps as { activityState?: unknown } | null) + ?.activityState; + if (value !== null && typeof value === 'object') { + return true; + } + } + return false; +} + +/** Re-reads `host`'s clip from its current fiber, if its committed props changed. */ +function refresh(host: HostAncestor, resolved: readonly FiberLike[]): void { + const props = (resolved[host.index] as FiberLike).memoizedProps; + if (props !== host.props) { + const overflow = styleValue((props as { style?: unknown } | null)?.style, 'overflow'); + host.clips = !host.boundary && (host.scrolls ? overflow !== 'visible' : overflow === 'hidden'); + // Only once read: a read that threw is tried again next time. + host.props = props; + } +} + +/** + * Whether `screen` hides the view in the committed tree: `activityState` 0 + * on its current fiber, not driven by an Animated node on the wrapper above + * it, under a react-native-screens container that detaches inactive screens. + */ +function screenHides(screen: HostAncestor, ancestry: Ancestry, resolved: readonly FiberLike[]): boolean { + const activityState = ((resolved[screen.index] as FiberLike).memoizedProps as { activityState?: unknown } | null) + ?.activityState; + return ( + activityState === INACTIVE_SCREEN && + SCREEN_CONTAINER_HOST_TYPES.has(ancestry.path[screen.above]?.type) && + !animatedActivity(resolved, screen.index + 1, screen.above) + ); +} + +/** + * Whether a react-native-screens screen above the view is inactive in the + * committed tree (see `screenHides`). Also reads which hosts clip, for + * {@link clipToAncestors}. When anything cannot be read (the path does not + * resolve, or a props read throws) the answer is `false` and nothing is + * clipped either. + */ +export function isHidden(ancestry: Ancestry): boolean { + ancestry.readable = false; + const resolved = resolve(ancestry); + if (resolved === undefined) { + return false; + } + // Props are the app's: a getter on one can throw. + try { + for (const host of ancestry.hosts) { + refresh(host, resolved); + } + const hidden = ancestry.screens.some((screen) => screenHides(screen, ancestry, resolved)); + ancestry.readable = true; + return hidden; + } catch { + return false; + } +} + +/** + * The screens alone of {@link isHidden}, for a view withdrawn as hidden to + * check every frame: it resolves the path only down to the nearest screen, + * and only once a pass for every view under the same screen. + */ +export function screensHide(ancestry: Ancestry): boolean { + const nearest = ancestry.screens[0]; + if (nearest === undefined) { + return false; + } + const key = ancestry.path[nearest.index] as FiberLike; + const known = passScreens?.get(key); + if (known !== undefined) { + return known; + } + const resolved = resolve(ancestry, nearest.index); + let hidden: boolean; + // An unresolved path (`undefined`) throws here as a props getter can. + try { + hidden = ancestry.screens.some((screen) => screenHides(screen, ancestry, resolved as FiberLike[])); + } catch { + hidden = false; + } + passScreens?.set(key, hidden).set(key.alternate, hidden); + return hidden; +} + +/** {@link screensHide}'s answers in the running pass, by both fibers of the nearest screen. */ +let passScreens: Map | undefined; + +/** + * The nodes whose movement can change how `ancestry`'s clipping ancestors + * (as the last {@link isHidden} read them) cut the view: every clipping + * host itself, whose bounds are the clip (a viewport that grows uncovers + * more of the view), and a ScrollView's content container, which moves as + * it scrolls. + */ +export function clipWatchNodes(ancestry: Ancestry): FiberLike[] { + if (!ancestry.readable) { + return []; + } + return ancestry.hosts + .filter((host) => host.clips) + .flatMap((host) => { + const clipper = ancestry.path[host.index] as FiberLike; + const below = ancestry.path[host.index - 1]; + return host.scrolls && below?.tag === FiberTag.HostComponent ? [below, clipper] : [clipper]; + }); +} + +/** Public instances of watched nodes, looked up once each. */ +const nodeInstances = new WeakMap(); + +/** `fiber`'s window rectangle, as {@link measureHost} answers, measured at most once a pass. */ +export function measureFiber(fiber: FiberLike): WindowRect | null | undefined { + let instance = nodeInstances.get(fiber); + if (instance === undefined) { + instance = publicInstanceOf(fiber); + nodeInstances.set(fiber, instance); + } + return measureInstance(instance); +} + +function publicInstanceOf(fiber: FiberLike): { measureInWindow: MeasureInWindow } { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy, like surface.ts: only a renderer has it. + const rendererProxy = require('react-native/Libraries/ReactNative/RendererProxy') as { + getPublicInstanceFromInternalInstanceHandle: (fiber: unknown) => { measureInWindow: MeasureInWindow }; + }; + return rendererProxy.getPublicInstanceFromInternalInstanceHandle(fiber); +} + +/** + * The clipping ancestors measured in the running {@link measurePass}, by + * public instance: views under one ScrollView all clip to it, and measure it + * once a pass. `undefined` outside a pass, which measures every time. + */ +let passMeasurements: Map | undefined; + +/** + * Runs `pass` with each clipping ancestor measured, and each ancestor's + * current fiber resolved, at most once. Only for a synchronous stretch of JS + * (the re-measure loop's tick): no commit can move anything while it runs. + */ +export function measurePass(pass: () => void): void { + passMeasurements = new Map(); + passChildren = new Map(); + passScreens = new Map(); + try { + pass(); + } finally { + passMeasurements = undefined; + passChildren = undefined; + passScreens = undefined; + } +} + +/** + * `host`'s window rectangle; `null` for an exact 0/0/0/0, which Fabric + * answers for a node or layout it cannot find; `undefined` unless it answers + * at once with finite numbers. + */ +function measureHost(host: HostAncestor, fiber: FiberLike): WindowRect | null | undefined { + return measureInstance((host.instance ??= publicInstanceOf(fiber))); +} + +function measureInstance(instance: { measureInWindow: MeasureInWindow }): WindowRect | null | undefined { + if (passMeasurements?.has(instance)) { + return passMeasurements.get(instance); + } + let measured: WindowRect | null | undefined; + instance.measureInWindow((x, y, width, height) => { + const answer = { x, y, width, height }; + if (finite(answer)) { + measured = x === 0 && y === 0 && width === 0 && height === 0 ? null : answer; + } + }); + passMeasurements?.set(instance, measured); + return measured; +} + +function finite({ x, y, width, height }: WindowRect): boolean { + return Number.isFinite(x) && Number.isFinite(y) && Number.isFinite(width) && Number.isFinite(height); +} + +/** + * The part of `rect` inside every ancestor that clips now (as the last + * {@link isHidden} read them), or `null` when none of it is. + * + * Fails closed: `rect` itself, unclipped, when it is not finite, the last + * read was not complete, or any clipping ancestor throws, answers late or + * answers with a non-finite rectangle. An ancestor answering 0/0/0/0 clips + * nothing. Measures nothing, and returns `rect` as it is, when nothing + * clips. + */ +export function clipToAncestors(rect: WindowRect, ancestry: Ancestry): WindowRect | null { + if (!finite(rect) || !ancestry.readable) { + return rect; + } + const clips: Array = []; + try { + for (const host of ancestry.hosts) { + if (host.clips) { + clips.push(measureHost(host, ancestry.path[host.index] as FiberLike)); + } + } + } catch { + return rect; + } + if (clips.includes(undefined)) { + return rect; + } + const known = clips.filter((clip): clip is WindowRect => clip !== null); + if (known.length === 0) { + return rect; + } + let left = rect.x; + let top = rect.y; + let right = rect.x + rect.width; + let bottom = rect.y + rect.height; + for (const clip of known) { + left = Math.max(left, clip.x); + top = Math.max(top, clip.y); + right = Math.min(right, clip.x + clip.width); + bottom = Math.min(bottom, clip.y + clip.height); + } + if (right <= left || bottom <= top) { + return null; + } + return { x: left, y: top, width: right - left, height: bottom - top }; +} diff --git a/packages/react-native/src/secure/hold.ts b/packages/react-native/src/secure/hold.ts new file mode 100644 index 00000000..f1f7286a --- /dev/null +++ b/packages/react-native/src/secure/hold.ts @@ -0,0 +1,99 @@ +import type { WindowRect } from '../viewtree/fiber'; + +/** + * How long a `` keeps covering what it covered before it shrank, + * moved or was hidden. + * + * `measureInWindow` and committed props describe the shadow tree; native + * draws it a frame or more later, and a LayoutAnimation (`configureNext`, as + * KeyboardAvoidingView uses, about 250 ms) only reaches its end state at the + * end of the animation. Coverage taken away on shadow-tree evidence alone + * could uncover a field native still draws where it was. So coverage is + * added at once and removed late: what a view covered stays published for + * this long after it stopped covering it, and a hidden view withdraws only + * once it has been hidden this long without a break. + * + * What is held is what the view covered, not the path between: a native + * animation that carries the view further than its own size in one step + * draws positions neither the old nor the new rectangle covers. Main, which + * publishes only where the view is now, covers them no better. + */ +export const SECURE_SHRINK_HOLD_MS = 500; + +/** The width of one hold window: what leaves within one is held as one box. */ +const WINDOW_MS = 100; + +interface Held { + /** The window this box collects what left during. */ + readonly window: number; + left: number; + top: number; + right: number; + bottom: number; +} + +/** + * One instance's coverage over time: the rectangle it covers now, and, per + * {@link WINDOW_MS} window of the last {@link SECURE_SHRINK_HOLD_MS}, the + * bounding box of what it stopped covering then. At most a handful of + * boxes, however much the view moves. + */ +export class CoverageHold { + private current: WindowRect | null = null; + private held: Held[] = []; + + /** + * Sets what the view covers now (`null`: nothing, hidden or cut away) and + * returns everything to publish: the current rectangle and every held box. + */ + update(visible: WindowRect | null, now: number): WindowRect[] { + const previous = this.current; + if (previous !== null && !sameRect(previous, visible)) { + this.hold(previous, now); + } + this.current = visible; + return this.publishable(now); + } + + /** What to publish now, after dropping what has been held long enough. */ + publishable(now: number): WindowRect[] { + this.held = this.held.filter((box) => (box.window + 1) * WINDOW_MS + SECURE_SHRINK_HOLD_MS > now); + const out = this.held.map(({ left, top, right, bottom }) => ({ + x: left, + y: top, + width: right - left, + height: bottom - top, + })); + return this.current === null ? out : [this.current, ...out]; + } + + /** Whether anything is still held: what is published will change on its own. */ + holding(): boolean { + return this.held.length > 0; + } + + /** Forgets everything, as unmounting or disabling does. */ + clear(): void { + this.current = null; + this.held = []; + } + + private hold(rect: WindowRect, now: number): void { + const window = Math.floor(now / WINDOW_MS); + const last = this.held[this.held.length - 1]; + const right = rect.x + rect.width; + const bottom = rect.y + rect.height; + if (last !== undefined && last.window === window) { + last.left = Math.min(last.left, rect.x); + last.top = Math.min(last.top, rect.y); + last.right = Math.max(last.right, right); + last.bottom = Math.max(last.bottom, bottom); + return; + } + this.held.push({ window, left: rect.x, top: rect.y, right, bottom }); + } +} + +function sameRect(a: WindowRect, b: WindowRect | null): boolean { + return b !== null && a.x === b.x && a.y === b.y && a.width === b.width && a.height === b.height; +} diff --git a/packages/react-native/src/secure/measureLoop.ts b/packages/react-native/src/secure/measureLoop.ts index f025499d..1e8f07a3 100644 --- a/packages/react-native/src/secure/measureLoop.ts +++ b/packages/react-native/src/secure/measureLoop.ts @@ -1,4 +1,7 @@ import { errorName } from '../errorName'; +import { measureFiber, measurePass } from './ancestry'; +import { publishTogether } from './registry'; +import type { FiberLike, WindowRect } from '../viewtree/fiber'; /** * How often every mounted `` re-measures itself. @@ -19,7 +22,7 @@ const measurers = new Set(); /** Stops the running interval; set exactly while one is running. */ let stopTimer: (() => void) | undefined; -function tick(): void { +function measureAll(): void { // A snapshot: a measurer may add or remove one while the tick runs. for (const { measure } of [...measurers]) { // One broken measurer must not leave the rest stale for this tick. @@ -31,6 +34,50 @@ function tick(): void { } } +/** Parked instances' checks (clipped and still): every tick runs every one. */ +const parked = new Set(); + +/** + * Every parked instance's check, every tick. Each measures its view alone + * (no ancestors, nothing published while it has not moved), so a tick costs + * one measurement per parked instance, as a tick-mode instance's does; no + * frame costs any. + */ +function checkParked(): void { + // A snapshot: a check may park or unpark one while the tick runs. + for (const { measure } of [...parked]) { + try { + measure(); + } catch (error) { + console.warn('[Bugsee] a secure-region measurer threw', errorName(error)); + } + } +} + +/** One pass: a ScrollView shared by many measurers is measured once. */ +function tick(): void { + measurePass(() => + publishTogether(() => { + measureAll(); + checkParked(); + // A lane that failed is retried here, once a tick, not every frame. + }, true), + ); +} + +/** + * Adds `check` to the parked instances every tick re-checks, until the + * returned function is called. Only while some measurer keeps the tick + * running. + */ +export function addParked(check: () => void): () => void { + const registration: Registration = { measure: check }; + parked.add(registration); + return () => { + parked.delete(registration); + }; +} + /** * Calls `measure` every `SECURE_REMEASURE_MS` until the returned function is * called. One interval serves every measurer, and it runs only while there is @@ -52,3 +99,158 @@ export function addMeasurer(measure: () => void): () => void { } }; } + +/** A frame, for the fallback where there is no `requestAnimationFrame`. */ +export const FRAME_MS = 16; + +/** Instances that re-measure every frame: withdrawn as hidden, or clipped. */ +const frameMeasurers = new Set(); + +/** Cancels the pending frame; set exactly while one is pending. */ +let cancelFrame: (() => void) | undefined; + +/** + * Runs `callback` before the next frame, returning what cancels it. Looked + * up per call, so it follows whatever the runtime provides at the time. + */ +function requestFrame(callback: () => void): () => void { + const { requestAnimationFrame: request, cancelAnimationFrame: cancel } = globalThis as { + requestAnimationFrame?: (callback: () => void) => number; + cancelAnimationFrame?: (handle: number) => void; + }; + if (request === undefined || cancel === undefined) { + const timer = setTimeout(callback, FRAME_MS); + return () => clearTimeout(timer); + } + const handle = request(callback); + return () => cancel(handle); +} + +interface WatchedNode { + /** What moved callbacks to call; a callback per subscription. */ + readonly subscribers: Set<{ readonly moved: () => void }>; + /** Where the node was at the last frame (or when first watched). */ + last: string; + /** Its two fibers' props objects then: a commit that changes either may change how it clips. */ + props: readonly unknown[]; +} + +/** Nodes measured every frame (a ScrollView's content, a clipping view), by fiber. */ +const watched = new Map(); + +function where(rect: WindowRect | null | undefined): string { + return rect == null ? String(rect) : `${rect.x},${rect.y},${rect.width},${rect.height}`; +} + +function whereNow(fiber: FiberLike): string { + try { + return where(measureFiber(fiber)); + } catch { + // Unknown, as a node that does not answer is. + return where(undefined); + } +} + +/** + * The props objects of both fibers of `fiber`'s pair. Whichever is + * committed, a commit that changes its props replaces one of them, so + * comparing both by identity catches every such commit (and, harmlessly, + * renders that never commit). + */ +function propsOf(fiber: FiberLike): unknown[] { + return [fiber.memoizedProps, fiber.alternate?.memoizedProps]; +} + +/** + * Measures every watched node once, and wakes the subscribers of each that + * moved or whose props changed (an `overflow` that stops clipping moves + * nothing). + */ +function watchNodes(): void { + for (const [fiber, node] of [...watched]) { + const now = whereNow(fiber); + const props = propsOf(fiber); + const changed = now !== node.last || props.some((value, index) => value !== node.props[index]); + node.last = now; + node.props = props; + if (changed) { + for (const { moved } of [...node.subscribers]) { + moved(); + } + } + } +} + +/** Watched nodes, then every frame measurer, in one measure pass; then the next frame if any are left. */ +function frame(): void { + cancelFrame = undefined; + try { + measurePass(() => + publishTogether(() => { + watchNodes(); + for (const { measure } of [...frameMeasurers]) { + try { + measure(); + } catch (error) { + console.warn('[Bugsee] a secure-region measurer threw', errorName(error)); + } + } + }), + ); + } finally { + // In a finally: nothing thrown above (a warn included) may stop the loop. + if (frameMeasurers.size > 0 || watched.size > 0) { + cancelFrame ??= requestFrame(frame); + } + } +} + +/** + * Measures `fiber` every frame, and calls `moved` whenever it is somewhere + * else than the frame before, until the returned function is called. Every + * subscriber of one fiber shares one measurement a frame, and so does every + * fiber of one host. + */ +export function watchNode(fiber: FiberLike, moved: () => void): () => void { + let node = watched.get(fiber); + if (node === undefined) { + // Measured at once: a move before the next frame is a move. + node = { subscribers: new Set(), last: whereNow(fiber), props: propsOf(fiber) }; + watched.set(fiber, node); + } + const subscription = { moved }; + node.subscribers.add(subscription); + cancelFrame ??= requestFrame(frame); + const target = node; + return () => { + target.subscribers.delete(subscription); + if (target.subscribers.size === 0 && watched.get(fiber) === target) { + watched.delete(fiber); + } + stopFramesIfIdle(); + }; +} + +function stopFramesIfIdle(): void { + if (frameMeasurers.size === 0 && watched.size === 0) { + cancelFrame?.(); + cancelFrame = undefined; + } +} + +/** + * Calls `measure` before every frame until the returned function is called. + * One frame callback serves every frame measurer, in one measure pass, so a + * ScrollView they share is measured once a frame, and it runs only while + * there is at least one. + */ +export function addFrameMeasurer(measure: () => void): () => void { + const registration: Registration = { measure }; + frameMeasurers.add(registration); + cancelFrame ??= requestFrame(frame); + + return () => { + frameMeasurers.delete(registration); + stopFramesIfIdle(); + }; +} diff --git a/packages/react-native/src/secure/registry.ts b/packages/react-native/src/secure/registry.ts index 7c9baf8e..c02f6c38 100644 --- a/packages/react-native/src/secure/registry.ts +++ b/packages/react-native/src/secure/registry.ts @@ -1,4 +1,5 @@ import NativeBugsee from '../NativeBugsee'; +import { errorName } from '../errorName'; import { flattenSecureRectangles } from './rectangles'; import type { SecureRectangle } from './rectangles'; import { MAIN_SURFACE } from './surface'; @@ -37,17 +38,38 @@ function publishKey(display: number, surface: number): string { return `${display}:${surface}`; } +/** + * The scale each lane's union was last flattened at, for as long as native + * holds that union. Absent while a lane's last publish has not reached the + * bridge, so nothing on it is skipped until one does. + */ +const laneScale = new Map(); + function sameList(a: readonly number[], b: readonly number[]): boolean { return a.length === b.length && a.every((value, index) => value === b[index]); } +function sameRectangles(a: readonly SecureRectangle[], b: readonly SecureRectangle[]): boolean { + if (a.length !== b.length) { + return false; + } + for (let index = 0; index < a.length; index += 1) { + const [p, q] = [a[index]!, b[index]!]; + if (p.x !== q.x || p.y !== q.y || p.width !== q.width || p.height !== q.height) { + return false; + } + } + return true; +} + /** * Publishes the union of every owner's rectangles on `display` for each * surface that has (or had) owners there. * * Re-flattened every time rather than cached per owner: the pixel ratio is - * read per publish (see `secureRectangleScale`), so a ratio change reaches the - * whole display the next time any owner on it moves. + * read per publish (see `secureRectangleScale`). A ratio change reaches each + * lane the next time any owner on it publishes: an owner that has not moved + * skips only while its lane was flattened at the current ratio. * * Skipping an unchanged union costs nothing observable: both native stores * already keep their version still for a no-op write. It saves a bridge hop @@ -67,9 +89,11 @@ function publish(display: number, surfaces: ReadonlySet): void { const key = publishKey(display, surface); const last = published.get(key); if (last !== undefined && sameList(last, flat)) { + laneScale.set(key, scale); continue; } + laneScale.delete(key); if (surface === MAIN_SURFACE) { NativeBugsee.setSecureRectangles(display, flat); } else { @@ -78,6 +102,7 @@ function publish(display: number, surfaces: ReadonlySet): void { // Only after the call returned: a publish that threw was not published, and // recording it would skip the retry as "unchanged". published.set(key, flat); + laneScale.set(key, scale); } } @@ -87,6 +112,11 @@ function publish(display: number, surfaces: ReadonlySet): void { * * Throws, changing nothing, if any rectangle is malformed. The owner's * previous rectangles stay published: a bad call never uncovers a region. + * + * An owner republishing exactly what it holds, on the same lane, costs a + * comparison and nothing else (every mounted `` does so every + * re-measure it has not moved), unless the lane's last publish failed or the + * pixel ratio changed since. */ export function setOwnerRectangles( owner: SecureOwner, @@ -94,10 +124,20 @@ export function setOwnerRectangles( rectangles: readonly SecureRectangle[], surface: number = MAIN_SURFACE, ): void { + const previous = owners.get(owner); + if ( + previous !== undefined && + previous.display === display && + previous.surface === surface && + laneScale.get(publishKey(display, surface)) === secureRectangleScale() && + sameRectangles(previous.rectangles, rectangles) + ) { + return; + } + // Validation first, before the entry changes. flattenSecureRectangles(rectangles, secureRectangleScale()); - const previous = owners.get(owner); // A copy: the caller keeps its array and objects, and an edit through them // must not change what a later publish for some other owner sends. owners.set(owner, { @@ -111,9 +151,78 @@ export function setOwnerRectangles( surfaces.add(previous.surface); } // The new display is covered before the old one is uncovered. - publish(display, surfaces); + publishOrDefer(display, surfaces); if (previous !== undefined && previous.display !== display) { - publish(previous.display, new Set([previous.surface])); + publishOrDefer(previous.display, new Set([previous.surface])); + } +} + +/** The lanes changed inside {@link publishTogether}, by display, in the order they changed. */ +let deferred: Map> | undefined; + +/** + * Lanes whose last publish inside {@link publishTogether} threw: the next + * call that retries publishes them again, whether or not anything on them + * changed (an owner that has not moved never asks again). + */ +let failed = new Map>(); + +/** Lanes warned about since they last published: one warning per lane per outage. */ +const warned = new Set(); + +function addLanes(into: Map>, display: number, surfaces: ReadonlySet): void { + const lanes = into.get(display) ?? new Set(); + surfaces.forEach((surface) => lanes.add(surface)); + into.set(display, lanes); +} + +function publishOrDefer(display: number, surfaces: ReadonlySet): void { + if (deferred === undefined) { + publish(display, surfaces); + return; + } + addLanes(deferred, display, surfaces); +} + +/** + * Runs `changes`, publishing every lane they changed once, at the end, + * rather than once per change: every instance re-measured in one tick or + * frame changes its lane, and each publish re-flattens the lane's whole + * union. A lane whose publish throws is logged once until it publishes + * again, and published again by the next call that `retryFailed` (the tick, + * so at most every 100 ms), changed or not; a call that changes it publishes + * it anyway. Inside another call, it simply runs `changes`. + */ +export function publishTogether(changes: () => void, retryFailed = false): void { + if (deferred !== undefined) { + changes(); + return; + } + deferred = new Map(); + if (retryFailed) { + failed.forEach((surfaces, display) => addLanes(deferred as Map>, display, surfaces)); + failed = new Map(); + } + try { + changes(); + } finally { + const lanes = deferred; + deferred = undefined; + for (const [display, surfaces] of lanes) { + const keys = [...surfaces].map((surface) => publishKey(display, surface)); + try { + publish(display, surfaces); + // Still listed as failed, a lane that went through here republishes + // the same list on the next retry, which the registry skips. + keys.forEach((key) => warned.delete(key)); + } catch (error) { + addLanes(failed, display, surfaces); + if (keys.some((key) => !warned.has(key))) { + keys.forEach((key) => warned.add(key)); + console.warn('[Bugsee] could not publish secure rectangles', errorName(error)); + } + } + } } } diff --git a/packages/react-native/src/secure/tracker.ts b/packages/react-native/src/secure/tracker.ts new file mode 100644 index 00000000..e435fd72 --- /dev/null +++ b/packages/react-native/src/secure/tracker.ts @@ -0,0 +1,255 @@ +import { ancestryOfInstance, clipToAncestors, clipWatchNodes, isHidden, screensHide } from './ancestry'; +import type { Ancestry } from './ancestry'; +import { CoverageHold } from './hold'; +import { addFrameMeasurer, addMeasurer, addParked, watchNode } from './measureLoop'; +import { clearOwner, setOwnerRectangles } from './registry'; +import { surfaceOfInstance } from './surface'; +import { errorName } from '../errorName'; +import type { FiberLike, WindowRect } from '../viewtree/fiber'; + +/** How many frames running a moving view's rectangle must stay put before it leaves the per-frame re-measure. */ +export const STEADY_FRAMES = 10; + +type MeasureInWindow = (callback: (x: number, y: number, width: number, height: number) => void) => void; + +/** + * How a mounted, enabled view is kept measured: + * - `tick`: wholly visible; re-measured on the 100 ms tick, as on main. + * - `active`: moving, or just shown or clipped; re-measured every frame + * until its rectangle has stayed put for {@link STEADY_FRAMES} frames. + * - `parked`: clipped and still. Its clipping nodes (a ScrollView and its + * content, a clipping view) are watched every frame, shared with every + * view under them, and a move wakes it; each frame also asks whether its + * screens hide it (once per screen a frame); the tick re-checks it, in + * turn, by measuring the view alone. + * - `hidden`: withdrawn under an inactive screen; each frame asks only + * whether the screens still hide it (once a frame per screen), and + * measures again the frame they do not. + */ +type Mode = 'tick' | 'active' | 'parked' | 'hidden'; + +/** + * Milliseconds on a clock that only goes forward: the hold must not end + * early because the wall clock was set forward (NTP, the user). Falls back + * to `Date.now` where there is no `performance.now`. + */ +export function monotonicNow(): number { + const { performance: perf } = globalThis as { performance?: { now?: () => number } }; + return typeof perf?.now === 'function' ? perf.now() : Date.now(); +} + +function where(rect: WindowRect | null): string { + // Stryker disable next-line StringLiteral -- any marker no rectangle reads as does + return rect === null ? 'none' : `${rect.x},${rect.y},${rect.width},${rect.height}`; +} + +/** + * One ``'s measuring and publishing, from mount (`start`) to + * unmount or disable (`stop`). Coverage is added at once and removed late + * (see `SECURE_SHRINK_HOLD_MS`); `stop` removes it at once. + */ +export class SecureTracker { + private running = false; + private warned = false; + // Stryker disable next-line StringLiteral -- any mode but active, hidden or parked starts it on no loop + private mode: Mode = 'tick'; + private surface: number | null = null; + private ancestry: Ancestry | null = null; + private readonly hold = new CoverageHold(); + /** Frames (or checks) in a row the visible rectangle stayed put. */ + private steady = 0; + private lastVisible: string | undefined; + private lastSelf: string | undefined; + // Stryker disable next-line ArrayDeclaration -- the first watch replaces it whatever it holds + private watched: FiberLike[] = []; + private unwatch: Array<() => void> = []; + private removeTick: (() => void) | undefined; + private removeFrame: (() => void) | undefined; + private removeParked: (() => void) | undefined; + + constructor( + private readonly view: () => { measureInWindow: MeasureInWindow } | null, + private readonly token: object, + ) {} + + start(): void { + this.running = true; + this.measure(); + this.removeTick = addMeasurer(() => this.onTick()); + } + + stop(): void { + this.running = false; + this.removeTick?.(); + this.removeTick = undefined; + // Stryker disable next-line StringLiteral -- any mode but active, hidden or parked leaves every loop + this.enter('tick'); + this.watch([]); + this.surface = null; + this.ancestry = null; + this.hold.clear(); + this.steady = 0; + this.lastVisible = undefined; + this.lastSelf = undefined; + clearOwner(this.token); + } + + /** Measures the view and its clipping ancestors, and publishes what it covers. */ + measure(): void { + const view = this.view(); + if (!this.running || view === null) { + return; + } + try { + this.surface ??= surfaceOfInstance(view); + this.ancestry ??= ancestryOfInstance(view); + const above = this.ancestry; + if (isHidden(above)) { + this.publish(this.hold.update(null, monotonicNow())); + this.watch([]); + this.enter('hidden'); + return; + } + // Fabric answers every measurement at once, so the view and its + // clipping ancestors are measured in the same layout. Paper answers + // later, and its ancestors then cut nothing. + view.measureInWindow((x, y, width, height) => { + if (!this.running) { + return; + } + try { + this.measured({ x, y, width, height }, above); + } catch (error) { + this.failed(error); + } + }); + } catch (error) { + this.failed(error); + } + } + + private measured(self: WindowRect, above: Ancestry): void { + if (![self.x, self.y, self.width, self.height].every(Number.isFinite)) { + // A failed measurement: the last rectangle stands. + // Stryker disable next-line StringLiteral -- only the error's name is logged + throw new RangeError('BugseeSecure measured a non-finite rectangle'); + } + this.lastSelf = where(self); + const visible = clipToAncestors(self, above); + this.publish(this.hold.update(visible, monotonicNow())); + const clipped = visible === null || visible.width < self.width || visible.height < self.height; + const now = where(visible); + this.steady = now === this.lastVisible ? this.steady + 1 : 0; + this.lastVisible = now; + this.watch(clipped ? clipWatchNodes(above) : []); + this.enter(this.next(clipped)); + } + + private next(clipped: boolean): Mode { + if (this.mode === 'active') { + if (this.steady < STEADY_FRAMES) { + return 'active'; + } + return clipped ? 'parked' : 'tick'; + } + if (this.mode === 'hidden') { + return 'active'; + } + if (!clipped) { + return 'tick'; + } + return this.mode === 'parked' && this.steady > 0 ? 'parked' : 'active'; + } + + private onTick(): void { + if (this.mode === 'tick') { + this.measure(); + } else if (this.mode === 'parked' && this.hold.holding()) { + this.publish(this.hold.publishable(monotonicNow())); + } + } + + private onFrame(): void { + if (this.mode === 'active') { + this.measure(); + return; + } + const hides = screensHide(this.ancestry as Ancestry); + if (this.mode === 'parked') { + // A screen that goes inactive moves nothing a watch would see. + if (hides) { + this.measure(); + } + return; + } + // Hidden: once its screens stop hiding it, measure (which checks fully). + if (!hides) { + this.measure(); + } else if (this.hold.holding()) { + this.publish(this.hold.publishable(monotonicNow())); + } + } + + /** The tick's turn for a parked view: the view alone; anything moved, measure it all. */ + private checkParked(): void { + // Parked only while mounted, so the view is there. + const view = this.view() as { measureInWindow: MeasureInWindow }; + view.measureInWindow((x, y, width, height) => { + if (this.running && where({ x, y, width, height }) !== this.lastSelf) { + this.measure(); + } + }); + } + + private enter(mode: Mode): void { + if (mode === this.mode) { + return; + } + // Parked too, for its screens alone: once per screen a frame, or nothing. + const perFrame = mode !== 'tick'; + if (!perFrame) { + // Every mode but tick is on frames, so leaving one for tick has this. + (this.removeFrame as () => void)(); + this.removeFrame = undefined; + } + // Entering parked from elsewhere, nothing parked is registered to remove. + // Stryker disable next-line ConditionalExpression,StringLiteral -- so removing it anyway changes nothing + if (mode !== 'parked') { + this.removeParked?.(); + this.removeParked = undefined; + } + if (perFrame) { + this.removeFrame ??= addFrameMeasurer(() => this.onFrame()); + } + if (mode === 'parked') { + this.removeParked = addParked(() => this.checkParked()); + } + // A fresh count in every mode: woken back to active, it must stay put again. + this.steady = 0; + this.mode = mode; + } + + /** Watches `nodes` (replacing what was watched) and wakes on any of them moving. */ + private watch(nodes: FiberLike[]): void { + if (nodes.length === this.watched.length && nodes.every((node, index) => node === this.watched[index])) { + return; + } + this.unwatch.forEach((stop) => stop()); + this.watched = nodes; + // Any mode but tick or parked measures it the same frame and goes active. + // Stryker disable next-line StringLiteral -- so another mode name changes nothing + this.unwatch = nodes.map((node) => watchNode(node, () => this.enter('active'))); + } + + private publish(rectangles: WindowRect[]): void { + // Set by the measure that got here. + setOwnerRectangles(this.token, 0, rectangles, this.surface as number); + } + + private failed(error: unknown): void { + if (!this.warned) { + this.warned = true; + console.warn('[Bugsee] BugseeSecure could not measure', errorName(error)); + } + } +} diff --git a/packages/react-native/src/viewtree/fiber.ts b/packages/react-native/src/viewtree/fiber.ts index d5aeedca..9eff7ac8 100644 --- a/packages/react-native/src/viewtree/fiber.ts +++ b/packages/react-native/src/viewtree/fiber.ts @@ -70,6 +70,12 @@ export interface FiberLike { */ memoizedState: unknown; stateNode: unknown; + /** + * The other fiber of the pair React swaps between the committed tree and + * the one being rendered. Only `secure/ancestry.ts` reads it, to find which + * of the two is committed. + */ + alternate?: FiberLike | null; } export interface WindowRect { diff --git a/scripts/check-rn-compat.sh b/scripts/check-rn-compat.sh index 072d55a3..38b875f9 100755 --- a/scripts/check-rn-compat.sh +++ b/scripts/check-rn-compat.sh @@ -244,6 +244,51 @@ do done echo " Offscreen's hidden signal (memoizedState) and the SimpleMemo/Offscreen tag numbers are unchanged in both renderer bundles" +# (5) 's ancestry (src/secure/ancestry.ts, #86) depends on: +# - the committed tree: `root.current = finishedWork` at commit, and the +# fiber/alternate pairing of createWorkInProgress, so that the root's +# current tree, walked down by `.child`/`.sibling`, names the committed +# fiber of every stored pair; +# - a hidden Offscreen being hidden in the shadow tree (cloneHiddenInstance +# clones its hosts with `display: "none"`, which measures 0x0), so it needs +# no check of its own; +# - the RootNodeKind hosts it stops at: exactly ModalHostView and +# InputAccessory among React Native's own components, whose JS host names +# are their paperComponentNames; +# - an iOS ScrollView not clipping when styled overflow: visible +# (clipsToBounds follows getClipsContentToBounds), so such a ScrollView +# clips nothing; +# - the ScrollView host type names. +for RENDERER_BUNDLE in \ + "$RN_SRC/Libraries/Renderer/implementations/ReactFabric-dev.js" \ + "$RN_SRC/Libraries/Renderer/implementations/ReactFabric-prod.js" +do + BUNDLE_NAME="$(basename "$RENDERER_BUNDLE")" + grep -qE '\.current = finishedWork' "$RENDERER_BUNDLE" \ + || { echo "FAIL: $BUNDLE_NAME no longer commits by assigning root.current = finishedWork"; exit 1; } + grep -qE 'current\.alternate = workInProgress' "$RENDERER_BUNDLE" \ + || { echo "FAIL: $BUNDLE_NAME no longer pairs a fiber with its alternate in createWorkInProgress"; exit 1; } + grep -qE 'style: \{ display: "none" \}' "$RENDERER_BUNDLE" \ + || { echo "FAIL: $BUNDLE_NAME no longer hides an Offscreen's hosts with display: none in the shadow tree"; exit 1; } +done +ROOT_KINDS="$(grep -rlE 'traits\.set\(ShadowNodeTraits::Trait::RootNodeKind\)' "$RN_SRC/ReactCommon/react/renderer/components" \ + | grep -v '/components/root/' | sed -E 's#.*/([A-Za-z]+)ShadowNode\.h$#\1#' | sort | tr '\n' ' ')" +[ "$ROOT_KINDS" = "InputAccessory ModalHostView " ] \ + || { echo "FAIL: React Native's RootNodeKind components are now: $ROOT_KINDS (ancestry.ts ROOT_KIND_HOST_TYPES)"; exit 1; } +for PAPER_NAME in RCTModalHostView RCTInputAccessoryView; do + grep -rqE "paperComponentName: '$PAPER_NAME'" "$RN_SRC/Libraries" "$RN_SRC/src" \ + || { echo "FAIL: no component is registered as $PAPER_NAME any more"; exit 1; } +done +grep -qE 'clipsToBounds = _props->getClipsContentToBounds\(\)' \ + "$RN_SRC/React/Fabric/Mounting/ComponentViews/ScrollView/RCTScrollViewComponentView.mm" \ + || { echo "FAIL: the iOS ScrollView no longer sets clipsToBounds from getClipsContentToBounds"; exit 1; } +grep -qE "uiViewClassName: 'RCTScrollView'" "$RN_SRC/Libraries/Components/ScrollView/ScrollViewNativeComponent.js" \ + || { echo "FAIL: ScrollViewNativeComponent.js no longer names its host RCTScrollView"; exit 1; } +grep -qE "uiViewClassName: 'AndroidHorizontalScrollView'" \ + "$RN_SRC/Libraries/Components/ScrollView/AndroidHorizontalScrollViewNativeComponent.js" \ + || { echo "FAIL: AndroidHorizontalScrollViewNativeComponent.js no longer names its host AndroidHorizontalScrollView"; exit 1; } +echo " the commit and pairing, hidden Offscreen hosts, RootNodeKind hosts, ScrollView clipping and host names are unchanged" + echo "--- exception-handler internals guard" # Task 7.2: global ErrorUtils / promise-rejection capture depends on these # React Native surfaces staying put. Fail with a named reason the day any of