diff --git a/examples/bare/e2e/secure-perf.test.ts b/examples/bare/e2e/secure-perf.test.ts index fbebda86..cc948659 100644 --- a/examples/bare/e2e/secure-perf.test.ts +++ b/examples/bare/e2e/secure-perf.test.ts @@ -166,3 +166,56 @@ describeDevice(` cost of 200 still, clipped views on ${TARGET_NAME expect(Number(results.get('many-frames')!.p95)).toBeLessThan(0.5); }); }); + +/** + * Scenario `secure-perf-animated`: 52 `` under `Animated` + * components, as in a react-native-screens app (every screen is one): 50 + * rows (18 in Debug) in an `Animated.ScrollView` driving a native `Animated.event`, and 2 + * in a native-driven collapsing header. Ticks and frames are timed idle + * (`animated`) and while the list is scrolled every 400 ms + * (`animated-scrolling`). + */ +describeDevice(` cost under Animated components on ${TARGET_NAME}`, () => { + // Release times 52 views; Debug (React's dev build, several times slower) 20, the count of the other modes, so + // every mode keeps the one 2 ms budget in both builds. + const MODES_ANIMATED = ['animated', 'animated-frames', 'animated-scrolling', 'animated-scrolling-frames']; + let log: DeviceLog; + const results = new Map>(); + + beforeAll(async () => { + log = await startDeviceLog('#88 perf animated', '#88 perf animated'); + const run = await startRun('secure-perf-animated'); + const nonce = run.scenario.nonce; + for (const mode of MODES_ANIMATED) { + 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(); + } + }); + + describe.each(MODES_ANIMATED)('%s', (mode) => { + it('timed 52 views in Release, 20 in Debug', () => { + const result = results.get(mode)!; + expect(result.secures).toBe(result.dev === 'true' ? '20' : '52'); + // The frames too: the header's watched value keeps the frame loop running (2300-2400 frames a run). + expect(Number(result.ticks)).toBeGreaterThanOrEqual(MIN_TICKS); + }); + + it(`keeps p95 under ${P95_BUDGET_MS} ms`, () => { + expect(Number(results.get(mode)!.p95)).toBeLessThan(P95_BUDGET_MS); + }); + }); +}); diff --git a/examples/bare/e2e/secure-transitions.test.ts b/examples/bare/e2e/secure-transitions.test.ts new file mode 100644 index 00000000..49de3356 --- /dev/null +++ b/examples/bare/e2e/secure-transitions.test.ts @@ -0,0 +1,166 @@ +/** + * Follow-up to #87: `` stays covered while native still draws + * it after or beside React. + * + * Scenario `secure-transitions` (scenarios/secure-transitions.tsx): a view + * unmounted in place; a view in a `` opening and + * closing; a view under a native-driven `Animated` translateX. Markers after + * each change are lined up with native's own log of each publish, `BugseeRN + * secure published display=0 surface= raw=[...]`, sent only on a + * change, so the last one on a surface before a marker is what native held + * then. Android-only, like secure-visible.test.ts: iOS logs no such line. + */ +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=(\d+) raw=\[([^\]]*)\]/; + +/** How far the scenario's native-driven animation moves the view, in points (its NATIVE_SHIFT). */ +const NATIVE_SHIFT = 120; + +/** The bottom edge of a slide band, as the registry flattens it: past any display. */ +const BAND_BOTTOM_POINTS = 100_000; + +interface Box { + x: number; + y: number; + w: number; + h: number; +} + +interface Marker { + self: Box | null; + /** `native-moved`'s translateX, where the animation was stopped. */ + value: number; + ratio: number; + line: LogLine; +} + +function rects(flat: string): number[][] { + const values = flat.trim() === '' ? [] : flat.split(',').map(value => Number(value.trim())); + const out: number[][] = []; + for (let i = 0; i < values.length; i += 4) { + out.push(values.slice(i, i + 4)); + } + return out; +} + +/** 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)]; +} + +const near = (a: number[], b: number[]): boolean => a.every((value, i) => Math.abs(value - b[i]!) <= 1); + +describeAndroid(` stays covered through native transitions, on ${TARGET_NAME}`, () => { + let log: DeviceLog; + let run: Run; + const markers = new Map(); + + beforeAll(async () => { + log = await startDeviceLog('#87', 'secure transitions'); + await adb('shell', 'setprop', 'log.tag.BugseeRN', 'DEBUG'); + run = await startRun('secure-transitions'); + const nonce = run.scenario.nonce; + report('banner', run.banner.text.trim()); + + const marker = new RegExp( + `BUGSEE_E2E secure-transitions phase=(\\S+)(?: value=(\\S+))? self=(\\S+) ratio=(\\S+) nonce=${escape(nonce)}`, + ); + const finished = new RegExp(`BUGSEE_E2E secure-transitions (done|threw).* nonce=${escape(nonce)}`); + const end = must(await log.waitFor(finished, 90_000, run.start), 'the scenario finishing', run.start); + expect(end.text).toContain('secure-transitions done'); + for (const line of log.all(marker, run.start)) { + const [, name, value, self, ratio] = marker.exec(line.text)!; + const [x, y, w, h] = self!.split(',').map(Number) as [number, number, number, number]; + const box = self === 'none' ? null : { x, y, w, h }; + markers.set(name!, { self: box, value: Number(value), ratio: Number(ratio), line }); + } + }); + + afterAll(async () => { + try { + await stopApp(); + } finally { + await adb('shell', 'setprop', 'log.tag.BugseeRN', "''").catch(() => {}); + stopDeviceLog(log); + resetScenario(); + } + }); + + const marker = (name: string): Marker => { + const found = markers.get(name); + if (found === undefined) { + throw new Error(`the scenario logged no phase ${name} (saw ${[...markers.keys()].join(', ')})`); + } + return found; + }; + + /** What native held on `surface` (any but the main one when `undefined`) at marker `name`. */ + function heldAt(name: string, surface?: number): number[][] { + const before = log + .all(PUBLISHED, run.start, marker(name).line.index) + .map(line => PUBLISHED.exec(line.text)!) + .filter(([, on]) => (surface === undefined ? Number(on) !== 0 : Number(on) === surface)); + const last = before[before.length - 1]; + report(`held at ${name}`, last?.[0] ?? 'nothing'); + return last === undefined ? [] : rects(last[2]!); + } + + it('an unmounted view stays covered while native may still draw it, then is removed', () => { + const mounted = marker('mounted'); + const box = pixels(mounted.self!, mounted.ratio); + + expect(heldAt('mounted', 0).some(rect => near(rect, box))).toBe(true); + expect(heldAt('held', 0).some(rect => near(rect, box))).toBe(true); + expect(heldAt('gone', 0).some(rect => near(rect, box))).toBe(false); + }); + + it('a Modal sliding in and out covers the band the view slides along, then only the view, then nothing', () => { + const settled = marker('modal-settled'); + const box = pixels(settled.self!, settled.ratio); + const isBand = (rect: number[]): boolean => + rect[0]! <= box[0]! + 1 && rect[2]! >= box[2]! - 1 && rect[3]! >= BAND_BOTTOM_POINTS * settled.ratio - 1; + + expect(heldAt('modal-opened').some(isBand)).toBe(true); + const atRest = heldAt('modal-settled'); + expect(atRest.some(isBand)).toBe(false); + expect(atRest.some(rect => near(rect, box))).toBe(true); + expect(heldAt('modal-closed').some(isBand)).toBe(true); + expect(heldAt('modal-gone')).toEqual([]); + }); + + it('a view a native-driven loop moved is covered where it is drawn, not where it was measured', () => { + const rest = marker('native-rest'); + const moved = marker('native-moved'); + // Stopped part-way through a loop. + expect(moved.value).toBeGreaterThan(0); + expect(moved.value).toBeLessThan(NATIVE_SHIFT); + report('native-moved measured at', { rest: rest.self, moved: moved.self, value: moved.value }); + const drawn = pixels({ ...rest.self!, x: rest.self!.x + moved.value }, moved.ratio); + // The mask joins where the view is drawn with where the shadow tree has it: it contains it. + const contains = (outer: number[], inner: number[]): boolean => + outer[0]! <= inner[0]! + 1 && + outer[1]! <= inner[1]! + 1 && + outer[2]! >= inner[2]! - 1 && + outer[3]! >= inner[3]! - 1; + + expect(heldAt('native-moved', 0).some(rect => contains(rect, drawn))).toBe(true); + }); +}); diff --git a/examples/bare/scenarios/secure-transitions.tsx b/examples/bare/scenarios/secure-transitions.tsx new file mode 100644 index 00000000..8c4d944d --- /dev/null +++ b/examples/bare/scenarios/secure-transitions.tsx @@ -0,0 +1,137 @@ +/** + * `` stays covered through what native draws after or beside + * React (the follow-up to #87), asserted by e2e/secure-transitions.test.ts. + * + * secure-transitions three steps, each logging `secure-transitions + * phase=` markers the test lines native's + * publishes up against: + * - unmount: a `` is unmounted (`unmounted`), and 250 ms + * later (`held`) and 1.5 s later (`gone`) the step logs again; + * - modal: a transparent `` holding a + * `` opens (`modal-opening`, then `modal-opened` 250 ms + * later and `modal-settled` 1.5 s later), and closes the same way + * (`modal-closing`, `modal-closed`, `modal-gone`); + * - native: a `` under an `Animated.View` whose translateX + * a native-driven `Animated.loop` moves from 0 to 120 points again and + * again; stopped part-way (a looping animation's value is synced back + * to JS, not to the shadow tree), 600 ms later (`native-moved`) the + * step logs the view's window rectangle as React Native measures it + * and `value=` the translateX it was stopped at. + * Every marker carries `self=x,y,w,h` (window points, `none` if unmounted) + * and `ratio=` the pixel ratio. + */ +import { useEffect, useRef, useState } from 'react'; +import { Animated, Modal, PixelRatio, StyleSheet, View } from 'react-native'; +import { BugseeSecure } from '@bugsee/react-native'; + +export const SECURE_TRANSITIONS_SCENARIO = 'secure-transitions'; + +/** What the native-driven animation moves the view by, in points. */ +export const NATIVE_SHIFT = 120; + +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 }; + +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 BOX = { position: 'absolute', top: 200, left: 40, width: 200, height: 80 } as const; + +export function SecureTransitionsProbe({ nonce }: { nonce: string }) { + const [plain, setPlain] = useState(true); + const [modal, setModal] = useState(false); + const [animated, setAnimated] = useState(false); + const shift = useRef(new Animated.Value(0)).current; + const twin = useRef(null); + const modalTwin = useRef(null); + const animatedTwin = useRef(null); + + useEffect(() => { + const log = async (phase: string, view: View | null) => { + const self = await measured(view); + mark(`secure-transitions phase=${phase} self=${self} ratio=${PixelRatio.get()} nonce=${nonce}`); + }; + (async () => { + await sleep(1500); + await log('mounted', twin.current); + setPlain(false); + await log('unmounted', null); + await sleep(250); + await log('held', null); + await sleep(1250); + await log('gone', null); + + setModal(true); + await log('modal-opening', null); + await sleep(250); + await log('modal-opened', modalTwin.current); + await sleep(1250); + await log('modal-settled', modalTwin.current); + setModal(false); + await log('modal-closing', null); + await sleep(250); + await log('modal-closed', null); + await sleep(1250); + await log('modal-gone', null); + + setAnimated(true); + await sleep(1500); + await log('native-rest', animatedTwin.current); + const loop = Animated.loop( + Animated.timing(shift, { toValue: NATIVE_SHIFT, duration: 700, useNativeDriver: true }), + ); + loop.start(); + await sleep(1000); + // Stopped as an app stops a loop; native sends the value back to JS once it has stopped. + loop.stop(); + await sleep(600); + const value = (shift as unknown as { __getValue(): number }).__getValue(); + await log(`native-moved value=${value}`, animatedTwin.current); + mark(`secure-transitions done nonce=${nonce}`); + })().catch((error: unknown) => { + mark(`secure-transitions threw ${String(error)} nonce=${nonce}`); + }); + }, [nonce, shift]); + + return ( + + {plain && ( + <> + + + + )} + setModal(false)}> + + + + + + {animated && ( + + + + + )} + + ); +} + +const styles = StyleSheet.create({ + stage: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0, backgroundColor: '#FFFFFF' }, + fill: { flex: 1 }, +}); diff --git a/examples/bare/scenarios/secure-visible.tsx b/examples/bare/scenarios/secure-visible.tsx index 8a5b3e11..e6a7d603 100644 --- a/examples/bare/scenarios/secure-visible.tsx +++ b/examples/bare/scenarios/secure-visible.tsx @@ -34,13 +34,20 @@ * baseline is taken. */ import { useEffect, useLayoutEffect, useRef, useState } from 'react'; -import { PixelRatio, ScrollView, StyleSheet, View } from 'react-native'; +import { Animated, 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'; +import { SECURE_TRANSITIONS_SCENARIO, SecureTransitionsProbe } from './secure-transitions'; -export const SECURE_VISIBLE_SCENARIOS = ['secure-visible', 'secure-perf', 'secure-perf-many'] as const; +export const SECURE_VISIBLE_SCENARIOS = [ + 'secure-visible', + 'secure-perf', + 'secure-perf-many', + 'secure-perf-animated', + SECURE_TRANSITIONS_SCENARIO, +] as const; export type SecureVisibleScenario = (typeof SECURE_VISIBLE_SCENARIOS)[number]; @@ -452,10 +459,156 @@ function SecurePerfManyProbe({ nonce }: { nonce: string }) { return {mountedSecures && }; } +/** + * secure-perf-animated: secure rows, and secure views in a native-driven + * header. 52 views in Release; in Debug (React's dev build, several times + * slower) 20, the count of the other modes, under the same 2 ms budget. + */ +const ANIMATED_ROWS = __DEV__ ? 18 : 50; +const HEADER_SECURES = 2; +const HEADER_HEIGHT = 60; + +/** A stand-in for react-native-screens' `Animated(RNSScreen)`: an Animated component with no transform. */ +const AnimatedScreen = Animated.createAnimatedComponent(View); + +/** + * secure-perf-animated: what views under `Animated` components cost, as in a + * react-native-screens app (every screen is an `Animated` component). One + * stand-in screen holds an `Animated.ScrollView` whose `onScroll` drives a + * native `Animated.event`, with 50 `` rows (18 in Debug), and a collapsing + * header (`diffClamp` of the scroll, interpolated and clamped, native) with + * 2 more. Ticks and frames are timed for 40 s idle (`animated`), then for + * 40 s while the list is scrolled every 400 ms (`animated-scrolling`). + */ +function SecurePerfAnimatedProbe({ nonce }: { nonce: string }) { + const [mountedSecures, setMountedSecures] = useState(false); + const list = useRef(null); + const scrollY = useRef(new Animated.Value(0)).current; + const headerY = useRef( + Animated.diffClamp(scrollY, 0, HEADER_HEIGHT).interpolate({ + inputRange: [0, HEADER_HEIGHT], + outputRange: [0, -HEADER_HEIGHT], + extrapolate: 'clamp', + }), + ).current; + + useEffect(() => { + endTimingWindow = timeLoopIntervals(); + setMountedSecures(true); + }, []); + + useEffect(() => { + if (!mountedSecures) { + return undefined; + } + let cancelled = false; + let scroller: ReturnType | undefined; + 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=${ANIMATED_ROWS + HEADER_SECURES} 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}`, + ); + }; + 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); + await timeFor('animated'); + if (cancelled) { + return; + } + const offsets = [0, 300, 900, 1500, 600]; + let step = 0; + scroller = setInterval(() => { + step += 1; + list.current?.scrollTo({ y: offsets[step % offsets.length], animated: false }); + }, 400); + await timeFor('animated-scrolling'); + clearInterval(scroller); + })().catch((error: unknown) => { + mark(`secure-perf threw ${String(error)} nonce=${nonce}`); + }); + return () => { + cancelled = true; + clearInterval(scroller); + }; + }, [mountedSecures, nonce]); + + return ( + + {mountedSecures && ( + + )} + + ); +} + +function AnimatedRows({ + list, + scrollY, + headerY, +}: { + list: { current: ScrollView | null }; + scrollY: Animated.Value; + headerY: Animated.AnimatedInterpolation; +}) { + // After every child's layout effect: the loop's interval has started. + useLayoutEffect(() => { + endTimingWindow?.(); + endTimingWindow = undefined; + }, []); + return ( + <> + + {Array.from({ length: ANIMATED_ROWS }, (_, i) => ( + + ))} + + + {Array.from({ length: HEADER_SECURES }, (_, i) => ( + + ))} + + + ); +} + export function SecureVisibleStage({ scenario, nonce }: { scenario: SecureVisibleScenario; nonce: string }) { if (scenario === 'secure-visible') { return ; } + if (scenario === SECURE_TRANSITIONS_SCENARIO) { + return ; + } + if (scenario === 'secure-perf-animated') { + return ; + } return scenario === 'secure-perf' ? : ; } @@ -473,4 +626,8 @@ const styles = StyleSheet.create({ manyScroll: { height: 100, flexGrow: 0, marginBottom: 20 }, manyContent: { height: 2000 }, manySecure: { height: 40, backgroundColor: '#EEEEEE' }, + animatedContent: { paddingTop: HEADER_HEIGHT }, + animatedRow: { height: 40, marginBottom: 8, backgroundColor: '#EEEEEE' }, + header: { position: 'absolute', top: 0, left: 0, right: 0, height: HEADER_HEIGHT, flexDirection: 'row' }, + headerSecure: { flex: 1, margin: 8, backgroundColor: '#DDDDDD' }, }); diff --git a/packages/react-native/README.md b/packages/react-native/README.md index 711000bb..84cb3ed0 100644 --- a/packages/react-native/README.md +++ b/packages/react-native/README.md @@ -70,6 +70,79 @@ running, or a token the backend rejects — without that being an error worth throwing on. Capture comes up asynchronously, so poll `getStatus()` rather than assuming a resolved `launch` means recording has begun. +### Hiding a view from recordings + +Wrap anything that must never be recorded in ``: + +```tsx +import { BugseeSecure } from '@bugsee/react-native'; + + + {accountNumber} + +``` + +The part of it that is on screen is masked, and taps on it are dropped, while +it is mounted and `enabled` (default `true`). It follows scrolling, hidden +screens, react-native-screens push and pop transitions, sliding ``s and +`Animated` transforms driven by the native driver, masking more rather than +less where it cannot tell exactly. `enabled={false}` unmasks at once; an +unmount keeps the mask for about half a second, as native may still draw the +view. + +Known limits: + +- Content that mounts in a pushed screen or an opening `` after it + came (behind a spinner), or that is first shown on the screen a pop + uncovers, is masked for the rest of the transition. When a screen came or + went is known only if a `` saw it; otherwise the first one to + look masks as if it happened then (the whole screen, or the Modal's band, + for under a second). +- A value a native event drives (an `Animated.event` in an `Animated` + component's `onScroll` or `onGestureEvent`, or `Animated.attachNativeEvent`, + which React Native's own ScrollView uses for `stickyHeaderIndices`) moves + with the scroll or the gesture. What it transforms is masked wherever it can + be drawn, for as long as the event is attached, idle or not: + - a clamped interpolation, one whose ends are flat (a swipeable row without + overshoot), or a `diffClamp`: over its output range; + - on Android, a ScrollView's `contentOffset`, vertical or horizontal (a + collapsing or parallax header, a sticky header): over the offsets the + ScrollView can reach, from its measured content and viewport, plus 64 dp + either way for a fling's overrun (AOSP flings 6 dp past an end; the + margin assumes no device flings more than ten times that). A view inside + the content of the ScrollView whose offset drives it (a sticky header, + from `stickyHeaderIndices` or a `SectionList`) moves with that content + too, natively ahead of what JS measures: it is masked over that + ScrollView's whole viewport; + - anything else (a gesture's translation through an interpolation that + extends, an iOS ScrollView, which bounces and can be scrolled anywhere by + `scrollTo`, a ScrollView in another React root, or under a scale other + than 1): the whole surface around it, cut by the clipping ancestors above + it (a list's viewport, say). +- Native events are followed from when `@bugsee/react-native` is first + evaluated: with Metro's `inlineRequires`, its first use. Import it (or call + `Bugsee.launch`) at the top of `index.js` to follow them from the start. + A value made native before then is masked as if anything could drive it, + until JS next animates or sets it. A native event attached without + `Animated` (code that calls React Native's native Animated module itself) + is not seen at all, and the value it drives is taken to be where JS last + had it. +- A transform animated from JS (`useNativeDriver: false`) is followed every + frame while its animation runs. Other styles a JS animation moves (`top`, + `left`, `margin`, a size) are followed by the 100 ms re-measure only. +- Reanimated runs its animations on the UI thread, out of JS's sight. A + Reanimated component whose style sets a transform through an animated + style, sets a shared value inline, or uses a CSS animation or transition + masks the whole surface around it; other Reanimated motion (an animated + style that adds a transform only later, layout animations, + `entering`/`exiting`, shared element transitions) is not followed. +- An interactive back gesture (iOS swipe-back, Android predictive back) moves + a mounted screen without anything JS can see. +- The mask is published in the same JS task that starts a native animation + (or sets, resets or offsets an `Animated.Value`), but the two reach native + separately: under heavy load the animation's first frame could still be + drawn before the mask lands. + ## Expo config plugin ```json diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts index b00559a4..ac12c4b3 100644 --- a/packages/react-native/src/index.ts +++ b/packages/react-native/src/index.ts @@ -5,6 +5,9 @@ import { wrap } from './viewtree/anchor'; import { collectWrapperFacts } from './wrapper/collect'; import { wrapperIdentity } from './wrapper/identity'; import { setOwnerRectangles } from './secure/registry'; +// For its effect, eagerly (Metro's inlineRequires defers only what is read): follows native Animated events +// from here on, so those attached before the first mounts are counted too. +import './secure/followEvents'; import type { SecureRectangle } from './secure/rectangles'; import { Status } from './status'; import { statusForEvent } from './wrapper/events'; diff --git a/packages/react-native/src/secure/BugseeSecure.tsx b/packages/react-native/src/secure/BugseeSecure.tsx index af9ab739..b915cbd3 100644 --- a/packages/react-native/src/secure/BugseeSecure.tsx +++ b/packages/react-native/src/secure/BugseeSecure.tsx @@ -47,9 +47,95 @@ export interface BugseeSecureProps extends ViewProps { * once per screen, whether its screens still hide it, and is back the frame * they do not. * + * Unmounting removes it late too: native draws the view until it mounts the + * commit that removed it, so what it covered stays published for + * `SECURE_SHRINK_HOLD_MS`. + * + * Native transitions draw it where no measurement can follow, so for + * `SECURE_TRANSITION_HOLD_MS` from the commit that starts one it also covers + * where they can. While a react-native-screens stack pushes or pops a + * screen, a view on that screen, or on the screen it uncovers or covers, + * covers by the screen's `stackAnimation`: its band across the screen for a + * sideways slide, the column below it for a slide from the bottom, itself + * for a fade, and the whole screen for the platform default or anything + * else. A view in a `` covers the band below + * it while the Modal slides in or out (also when `visible` turns false on a + * Modal that stays mounted). A screen replaced on its stack (`replace`) is + * animated out by the incoming screen's animation, and covered by it. A view + * that mounts, or is shown, in a screen or Modal still within the hold from + * its push or opening is covered too (content that arrives after a spinner), + * and re-measured every frame until it settles, so a Modal laid out again + * once its dialog is up is followed within a frame; so is a view first shown + * on the screen a pop uncovers (mounted while that screen was hidden, or + * during the pop), for the rest of the pop's hold. Each stack keeps what any + * view saw of its recent pushes and pops. When a screen or Modal came or + * went is known only if a `` saw it (one on it, or on another + * screen of its stack); otherwise the first view to look takes it as + * happening then, once per change (a stack read for the first time is + * compared with its render before). + * + * A native-driven `Animated` transform above it (`useNativeDriver: true`) + * never reaches the shadow tree, so it also covers every place that + * transform can draw it, joined with where it is measured: translations and + * scales (about `transformOrigin`) bounded from the animated value, its + * running timing animation, interpolations, `diffClamp` and arithmetic + * nodes. A value a native event is attached to (an `Animated.event` in an + * `Animated` component's props, such as an `Animated.ScrollView`'s + * `onScroll` or a gesture handler's `onGestureEvent`, or one attached with + * `Animated.attachNativeEvent`, as React Native's ScrollView does for its + * sticky headers) moves wherever the scroll or the gesture takes it, for as + * long as it is attached, idle or not, whatever animated it before. It is + * bounded only where that is provable: through an interpolation that clamps, + * or extends a flat last segment, at both ends (within its outputs, eased); + * through `diffClamp`; and, on Android, as a ScrollView's `contentOffset` + * (vertical or horizontal), within the offsets the ScrollView can reach + * (from its measured content and viewport, and a fling's overrun past either + * end, `SCROLL_OVERRUN`), unless it is under a scale other than 1, or inside + * that ScrollView's own content (a sticky header: its content moves by the + * same offset, natively ahead of the shadow tree). What cannot + * be bounded (any other event's value, an iOS ScrollView's offset, a rotation, a + * spring, a node React Native renamed) covers the whole surface, cut by the + * clipping ancestors above it, for as long as it cannot be. A view under a + * running native animation is re-measured every frame. When JS moves a value + * above it (starts an animation on it, the first with the native driver + * included, which makes it native and commits nothing; sets, resets or + * offsets it), the view is measured and published in that same JS task, + * before React Native hands the change to the UI thread; what native does to + * a still native value on its own (an animation ending) is seen by the next + * JS frame. A transform animated from JS (`useNativeDriver: false`) moves the + * view every frame through Fabric with no wrapped call: the view is + * re-measured every frame while the animation runs, and an idle value driven + * from JS keeps no frame loop running. Other styles a JS animation moves + * (`top`, `left`, a size) are followed by the 100 ms re-measure only. + * + * What drives a native value is followed from when the package is first + * evaluated (its entry imports `secure/followEvents` for that, so Metro's + * `inlineRequires` does not defer it to the first ``; it does + * defer the entry to its first use: import the package, or call + * `Bugsee.launch`, at the top of `index.js`): the + * events attached to it, and its being made native. A native value made + * native before that, or left wherever its last event took it, is unbounded + * until it next animates. Where React Native's internals cannot be followed + * (React Native moved them), no still native value is trusted: every one is + * unbounded. + * + * Known limits: Reanimated runs its animations on the UI thread, out of + * JS's sight; a Reanimated component whose style sets a transform through + * an animated style, sets a shared value inline, or uses a CSS animation or + * transition covers the whole surface, cut by the clipping ancestors above + * it; anything else Reanimated moves (an animated style that adds a + * transform only later, layout animations, `entering`/`exiting`, shared + * transitions) is not seen. An interactive back gesture moves a mounted + * screen with nothing JS sees. The cover published as JS starts a native + * animation and the animation itself reach native separately; under heavy + * load the UI thread could still draw its first frame before the cover lands. + * A native event attached without `Animated` (code that calls React + * Native's native Animated module itself) is not seen, and its value is + * taken where JS last had it. + * * Fails closed: a measurement that throws or is rejected leaves the last * rectangle published, and an ancestor that cannot be read or measured cuts - * nothing off it. Only unmounting or `enabled={false}` removes it at once. + * nothing off it. Only `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 @@ -67,6 +153,10 @@ export interface BugseeSecureProps extends ViewProps { * A plain function component on purpose (no `memo`, no `forwardRef`): the view * tree walk recognises it by identity. */ +/** No dependencies: an effect that runs once per mount. */ +// Stryker disable next-line ArrayDeclaration -- any constant list runs it once per mount alike +const ONCE_PER_MOUNT: readonly unknown[] = []; + export function BugseeSecure(props: BugseeSecureProps): ReactElement { const { enabled = true, onLayout, ...rest } = props; const ref = useRef>(null); @@ -78,16 +168,28 @@ export function BugseeSecure(props: BugseeSecureProps): ReactElement { const tracker = useRef(null); tracker.current ??= new SecureTracker(() => ref.current, token); + // Set while the instance unmounts. Declared before the effect below, so on + // unmount its cleanup runs first; a change of `enabled` alone never runs it. + // Stryker disable next-line BooleanLiteral -- the effect below sets it before any cleanup can read it + const unmounting = useRef(false); + useLayoutEffect(() => { + unmounting.current = false; + return () => { + unmounting.current = true; + }; + }, ONCE_PER_MOUNT); + // 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 - // rather than after it. Cleanup semantics are the same either way. + // rather than after it, and the cleanup of an unmount can still read which + // fibers that commit deletes. useLayoutEffect(() => { if (!enabled) { return undefined; } const current = tracker.current as SecureTracker; current.start(); - return () => current.stop(); + return () => (unmounting.current ? current.depart() : current.stop()); // Only `enabled`: the tracker is created once per instance. }, [enabled]); diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.review.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.review.test.tsx index d78d580d..380331ae 100644 --- a/packages/react-native/src/secure/__tests__/BugseeSecure.review.test.tsx +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.review.test.tsx @@ -9,6 +9,7 @@ import type { ReactElement } from 'react'; import { create } from 'react-test-renderer'; import type { ReactTestRenderer } from 'react-test-renderer'; import { BugseeSecure } from '../BugseeSecure'; +import { SECURE_TRANSITION_HOLD_MS } from '../transition'; import { MODAL_SCREEN_HOST_TYPE, SCREEN_HOST_TYPE } from '../ancestry'; import { STEADY_FRAMES } from '../tracker'; import { native } from '../../__mocks__/native'; @@ -71,6 +72,10 @@ beforeEach(() => { afterEach(() => { act(() => mounted.forEach((mountedRenderer) => mountedRenderer.unmount())); mounted = []; + // What an unmount leaves held runs out on the tick. + act(() => { + jest.advanceTimersByTime(SECURE_TRANSITION_HOLD_MS + 200); + }); expect(jest.getTimerCount()).toBe(0); jest.useRealTimers(); jest.restoreAllMocks(); diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.round3.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.round3.test.tsx index ae3806e6..109ebde1 100644 --- a/packages/react-native/src/secure/__tests__/BugseeSecure.round3.test.tsx +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.round3.test.tsx @@ -11,6 +11,7 @@ import type { ReactElement } from 'react'; import { create } from 'react-test-renderer'; import type { ReactTestRenderer } from 'react-test-renderer'; import { BugseeSecure } from '../BugseeSecure'; +import { SECURE_TRANSITION_HOLD_MS } from '../transition'; import { SCREEN_HOST_TYPE } from '../ancestry'; import { SECURE_SHRINK_HOLD_MS } from '../hold'; import { native } from '../../__mocks__/native'; @@ -71,6 +72,10 @@ beforeEach(() => { afterEach(() => { act(() => mounted.forEach((mountedRenderer) => mountedRenderer.unmount())); mounted = []; + // What an unmount leaves held runs out on the tick. + act(() => { + jest.advanceTimersByTime(SECURE_TRANSITION_HOLD_MS + 200); + }); expect(jest.getTimerCount()).toBe(0); jest.useRealTimers(); jest.restoreAllMocks(); @@ -229,18 +234,20 @@ describe('P2: a static clipped or hidden view costs nothing per frame', () => { 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(), + // Android's vertical ScrollView: its content container is the `RCTView` host the `View` component renders. + Array(5).fill(['RCTView', 'RCTScrollView']).flat(), ); }); - it('a parked view is woken, and follows, the frame its ScrollView content moves (control)', () => { + it('R4-1: a parked view is woken, and follows, the frame its (Android, vertical) ScrollView content moves', () => { 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)); + // The content container's host, below the `View` component. + moveTo(ancestorOfType(fiber, 'RCTView'), rect(0, -5040, 400, 9000)); selfRect = rect(10, 60, 200, 50); const before = measureInWindow.mock.calls.length; frames(); diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.round4.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.round4.test.tsx index bff67332..dd985029 100644 --- a/packages/react-native/src/secure/__tests__/BugseeSecure.round4.test.tsx +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.round4.test.tsx @@ -8,6 +8,7 @@ import type { ReactElement } from 'react'; import { create } from 'react-test-renderer'; import type { ReactTestRenderer } from 'react-test-renderer'; import { BugseeSecure } from '../BugseeSecure'; +import { SECURE_TRANSITION_HOLD_MS } from '../transition'; import { SECURE_SHRINK_HOLD_MS } from '../hold'; import { native } from '../../__mocks__/native'; import type { FiberLike, WindowRect } from '../../viewtree/fiber'; @@ -64,6 +65,10 @@ beforeEach(() => { afterEach(() => { act(() => mounted.forEach((mountedRenderer) => mountedRenderer.unmount())); mounted = []; + // What an unmount leaves held runs out on the tick. + act(() => { + jest.advanceTimersByTime(SECURE_TRANSITION_HOLD_MS + 200); + }); expect(jest.getTimerCount()).toBe(0); jest.useRealTimers(); jest.restoreAllMocks(); diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx index 55e383fd..2cd36048 100644 --- a/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.test.tsx @@ -9,6 +9,8 @@ import { create } from 'react-test-renderer'; import type { ReactTestRenderer } from 'react-test-renderer'; import type { LayoutChangeEvent } from 'react-native'; import { BugseeSecure } from '../BugseeSecure'; +import { SECURE_TRANSITION_HOLD_MS } from '../transition'; +import { SECURE_SHRINK_HOLD_MS } from '../hold'; import * as registry from '../registry'; import { native } from '../../__mocks__/native'; @@ -52,6 +54,10 @@ beforeEach(() => { afterEach(() => { act(() => mounted.forEach((renderer) => renderer.unmount())); mounted = []; + // What an unmount leaves held runs out on the tick. + act(() => { + jest.advanceTimersByTime(SECURE_TRANSITION_HOLD_MS + 200); + }); registry.clearOwner('manual:0'); expect(jest.getTimerCount()).toBe(0); jest.useRealTimers(); @@ -131,11 +137,16 @@ describe('', () => { expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [20, 140, 80, 220]); }); - it('removes its rectangle on unmount', () => { + // Native draws the view until it mounts the commit that removed it. + it('keeps its rectangle for the hold after unmount, then removes it', () => { const renderer = render(); act(() => renderer.unmount()); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); + tick(SECURE_SHRINK_HOLD_MS / 100 - 1); + expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); + tick(2); expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, []); expect(jest.getTimerCount()).toBe(0); }); @@ -145,6 +156,7 @@ describe('', () => { const renderer = render(); act(() => renderer.unmount()); + tick(SECURE_SHRINK_HOLD_MS / 100 + 1); expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [2, 4, 8, 12]); }); @@ -159,6 +171,7 @@ describe('', () => { expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, [...MEASURED, ...MEASURED]); update(renderer, <>); + tick(SECURE_SHRINK_HOLD_MS / 100 + 1); expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, MEASURED); }); @@ -234,6 +247,7 @@ describe('', () => { act(() => renderer.unmount()); pending?.(10, 20, 30, 40); + tick(SECURE_SHRINK_HOLD_MS / 100 + 1); expect(native.setSecureRectangles).toHaveBeenLastCalledWith(0, []); }); @@ -392,6 +406,7 @@ describe(' surfaces', () => { const renderer = renderWithNode(, { __internalInstanceHandle: fiberUnder(modalHost()) }); act(() => renderer.unmount()); + tick(SECURE_SHRINK_HOLD_MS / 100 + 1); expect(native.setSecureRectanglesOnSurface).toHaveBeenLastCalledWith(0, MODAL_TAG, []); }); diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.transitions.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.transitions.test.tsx new file mode 100644 index 00000000..956de5a6 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.transitions.test.tsx @@ -0,0 +1,2958 @@ +/** + * Follow-up to #87: a `` drawn where nothing masks it during + * native transitions (the Android deep test, phase 1). Each test states the + * correct behaviour and failed on 58dc051: + * - W1: a popped react-native-screens screen is drawn sliding out after + * React unmounted it, and the box was withdrawn at once; + * - C3: a `` draws its content below where it + * is laid out while it slides in, and lays it out again once the dialog is + * up, which only the next 100 ms tick saw; + * - B6: a native-driven `Animated` transform never reaches the shadow tree, + * so the mask stayed where React last rendered the view. + */ +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 { STEADY_FRAMES } from '../tracker'; +import { SCREEN_HOST_TYPE, ancestryOfFiber, contentOf } from '../ancestry'; +import { SECURE_SHRINK_HOLD_MS } from '../hold'; +import { SCROLL_OVERRUN, nativeMotion, nodeBound } from '../nativeAnimation'; +import * as drivers from '../drivers'; +import { followDrivers, forgetDrivers } from '../drivers'; +import { SECURE_TRANSITION_HOLD_MS, WHOLE_SURFACE, keyOf, noteBirth, stackMoving } from '../transition'; +import { native } from '../../__mocks__/native'; +import { FiberTag } from '../../viewtree/fiber'; +import type { FiberLike, WindowRect } from '../../viewtree/fiber'; +import type { FiberSpec } from '../../viewtree/__tests__/fakeFibers'; +import { + ancestorOfType, + commitProps, + composite, + fabricHost, + fakeRenderer, + moveTo, + pairWith, + rect, + root, + scrollView, + self, + selfIn, + selvesIn, + view, +} from './fakeAncestors'; +import type { FakeRenderer } from './fakeAncestors'; +import { FakeAnimatedEvent, FakeAnimatedValue, animated, implementation, madeNative } from './fakeAnimated'; + +// Android, but for the tests that switch it to iOS (read at use, after this file's imports). +const mockPlatform = { OS: 'android' }; +jest.mock('react-native', () => ({ + View: 'View', + PixelRatio: { get: () => 2 }, + Platform: { + get OS(): string { + return mockPlatform.OS; + }, + }, +})); +jest.mock('../../NativeBugsee', () => require('../../__mocks__/native').nativeMock); +// React Native's Animated internals, followed as the package follows an app's. +jest.mock('react-native/Libraries/Animated/AnimatedEvent', () => require('./fakeAnimated').eventModule); +jest.mock( + 'react-native/Libraries/Animated/AnimatedImplementation', + () => require('./fakeAnimated').implementationModule, +); +jest.mock('react-native/Libraries/Animated/Animated', () => require('./fakeAnimated').animatedModule); +jest.mock('react-native/Libraries/Animated/nodes/AnimatedValue', () => require('./fakeAnimated').valueModule); + +let renderer: FakeRenderer; +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; + +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 = []; + wait(SECURE_TRANSITION_HOLD_MS + 200); + expect(jest.getTimerCount()).toBe(0); + jest.useRealTimers(); + jest.restoreAllMocks(); +}); + +function render(fiber: FiberLike, element: ReactElement = ): ReactTestRenderer { + let testRenderer: ReactTestRenderer | undefined; + act(() => { + testRenderer = create(element, { + createNodeMock: () => ({ measureInWindow, __internalInstanceHandle: fiber }), + unstable_isConcurrent: true, + } as Parameters[1]); + }); + mounted.push(testRenderer as ReactTestRenderer); + return testRenderer as ReactTestRenderer; +} + +function unmount(testRenderer: ReactTestRenderer): void { + act(() => testRenderer.unmount()); + mounted = mounted.filter((entry) => entry !== testRenderer); +} + +function wait(ms: number): void { + act(() => { + jest.advanceTimersByTime(ms); + }); +} + +/** 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; +const lastOnSurface = (): unknown => native.setSecureRectanglesOnSurface.mock.lastCall; + +/** + * Gives every fiber on the way up from `fiber` an alternate: it existed + * before the commit running now; and its screens and Modal long before, as + * a view that measured them when they came would have seen. + */ +function existing(fiber: FiberLike): FiberLike { + for (let current = fiber.return; current !== null; current = current.return) { + current.alternate ??= { ...current, alternate: current }; + if (CARRIER_TYPES.has(current.type)) { + noteBirth(keyOf(current), -Infinity); + } + } + return fiber; +} + +/** Gives every fiber on the way up from `fiber` an alternate, its screens and Modal never seen come. */ +function unseen(fiber: FiberLike): FiberLike { + for (let current = fiber.return; current !== null; current = current.return) { + current.alternate ??= { ...current, alternate: current }; + } + return fiber; +} + +const CARRIER_TYPES: ReadonlySet = new Set([SCREEN_HOST_TYPE, 'RCTModalHostView']); + +/** Gives `ancestor` and every fiber above it an alternate: they were there before the commit running now. */ +function existingFrom(ancestor: FiberLike): void { + for (let current: FiberLike | null = ancestor; current !== null; current = current.return) { + current.alternate ??= { ...current, alternate: current }; + } +} + +/** Marks `fiber` deleted from its parent by the commit running now, as React lists it. */ +function deleting(fiber: FiberLike): void { + (fiber.return as FiberLike & { deletions?: FiberLike[] }).deletions = [fiber]; +} + +const SCREEN = rect(0, 0, 400, 800); + +/** The view on a screen in a react-native-screens stack (or `container`). */ +function onStackScreen(container = 'RNSScreenStack', inner: FiberSpec[] = [view([self()])]): FiberLike { + const screen = fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, inner); + return selfIn(root([fabricHost(container, null, {}, [screen])])); +} + +const MODAL_TAG = 42; + +/** The view in a `` with `props`, its content `content`. */ +function inModal(props: Record, content = rect(0, 0, 400, 800)): FiberLike { + const fiber = selfIn( + root([ + view([ + { + tag: FiberTag.HostComponent, + type: 'RCTModalHostView', + memoizedProps: props, + stateNode: { rect: null, tag: MODAL_TAG, canonical: { currentProps: props } }, + children: [fabricHost('RCTView', content, {}, [view([self()])])], + }, + ]), + ]), + ); + // The screen that opens it was there before. + existingFrom(ancestorOfType(ancestorOfType(fiber, 'RCTModalHostView'), 'RCTView')); + return fiber; +} + +describe('W1: a screen popped off a stack', () => { + it('covers the whole screen from the unmount until the transition hold is over', () => { + const fiber = existing(onStackScreen()); + const testRenderer = render(fiber); + expect(lastPublished()).toEqual([0, px(selfRect)]); + + deleting(ancestorOfType(fiber, SCREEN_HOST_TYPE)); + unmount(testRenderer); + + // Popped: still drawn, sliding or scaling inside the screen's bounds. + expect(lastPublished()).toEqual([0, [...px(SCREEN), ...px(selfRect)]]); + wait(SECURE_TRANSITION_HOLD_MS - 100); + expect((lastPublished() as [number, number[]])[1].slice(0, 4)).toEqual(px(SCREEN)); + wait(200); + expect(lastPublished()).toEqual([0, []]); + expect(jest.getTimerCount()).toBe(0); + }); + + it('covers the screen when what is deleted is above it', () => { + const fiber = selfIn( + root([ + fabricHost('RNSScreenStack', null, {}, [ + composite([fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()])])]), + ]), + ]), + ); + existing(fiber); + const testRenderer = render(fiber); + + // The stack item wrapping the screen, as native-stack removes a route. + deleting(ancestorOfType(fiber, SCREEN_HOST_TYPE).return as FiberLike); + unmount(testRenderer); + + expect(lastPublished()).toEqual([0, [...px(SCREEN), ...px(selfRect)]]); + }); + + it('reads the deletion from either fiber of the parent pair', () => { + const fiber = existing(onStackScreen()); + const testRenderer = render(fiber); + const screen = ancestorOfType(fiber, SCREEN_HOST_TYPE); + // The commit finished the stack's other fiber: that one lists the deletion. + ((screen.return as FiberLike).alternate as FiberLike & { deletions?: FiberLike[] }).deletions = [ + screen.alternate as FiberLike, + ]; + + unmount(testRenderer); + + expect(lastPublished()).toEqual([0, [...px(SCREEN), ...px(selfRect)]]); + }); + + it('covers the whole surface when the departing screen cannot be measured', () => { + const fiber = existing(onStackScreen()); + const testRenderer = render(fiber); + const screen = ancestorOfType(fiber, SCREEN_HOST_TYPE); + (screen.stateNode as { rect: WindowRect | null }).rect = rect(0, 0, 0, 0); + + deleting(screen); + unmount(testRenderer); + + expect(lastPublished()).toEqual([0, [...px(WHOLE_SURFACE), ...px(selfRect)]]); + }); + + it('a view removed from a screen that stays holds only itself, for the shrink hold', () => { + const fiber = existing(onStackScreen()); + const testRenderer = render(fiber); + + deleting(ancestorOfType(fiber, 'RCTView')); + unmount(testRenderer); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + wait(SECURE_SHRINK_HOLD_MS + 100); + expect(lastPublished()).toEqual([0, []]); + }); + + it('a screen removed from a tab container is not carried: it holds only itself', () => { + const fiber = existing(onStackScreen('RNSScreenContainer')); + const testRenderer = render(fiber); + + deleting(ancestorOfType(fiber, SCREEN_HOST_TYPE)); + unmount(testRenderer); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('enabled={false} still clears at once, on a screen being popped', () => { + const fiber = existing(onStackScreen()); + const testRenderer = render(fiber); + deleting(ancestorOfType(fiber, SCREEN_HOST_TYPE)); + + act(() => testRenderer.update()); + + expect(lastPublished()).toEqual([0, []]); + expect(jest.getTimerCount()).toBe(0); + }); + + it('a disabled view unmounts with nothing to hold', () => { + const fiber = existing(onStackScreen()); + const testRenderer = render(fiber, ); + deleting(ancestorOfType(fiber, SCREEN_HOST_TYPE)); + + unmount(testRenderer); + + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + expect(jest.getTimerCount()).toBe(0); + }); +}); + +describe('a screen pushed onto a stack', () => { + it('covers the whole screen as it is carried in, then only the view', () => { + // The stack was there; the push created the screen. + const fiber = onStackScreen(); + existingFrom(ancestorOfType(fiber, 'RNSScreenStack')); + render(fiber); + + expect(lastPublished()).toEqual([0, [...px(SCREEN), ...px(selfRect)]]); + wait(SECURE_TRANSITION_HOLD_MS + 100); + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('the first screen of a stack mounted with it (an app starting) covers only the view', () => { + render(onStackScreen()); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('a view mounted on a screen that was already there covers only itself', () => { + const fiber = existing(onStackScreen()); + render(fiber); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); + +describe('P2-6: the screen below a push or pop moves too', () => { + function stackOf(): { a: FiberLike; b: FiberLike } { + const tree = root([ + fabricHost('RNSScreenStack', null, {}, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()])]), + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2, stackAnimation: 'slide_from_right' }, [ + view([self()]), + ]), + ]), + ]); + const selves = selvesIn(tree); + const [a, b] = selves.sort((p, q) => { + const screen = (fiber: FiberLike): FiberLike => ancestorOfType(fiber, SCREEN_HOST_TYPE); + return screen(p).sibling === screen(q) ? -1 : 1; + }) as [FiberLike, FiberLike]; + return { a, b }; + } + + /** The rectangles of the last publish on display 0, as strings. */ + const rects = (): string[] => { + const published = (lastPublished() as [number, number[]])[1]; + const out: string[] = []; + for (let index = 0; index < published.length; index += 4) { + out.push(published.slice(index, index + 4).join()); + } + return out; + }; + const BAND = px(rect(0, 100, 400, 50)).join(); + + it('a push (sliding sideways) covers the view on the screen below with its band, for the transition hold', () => { + const { a, b } = stackOf(); + existing(a); + // The pushed screen, and what is in it, are new. + const pushed = ancestorOfType(b, SCREEN_HOST_TYPE); + for (let current: FiberLike | null = pushed; current !== null; current = current.child) { + current.alternate = null; + } + render(a); + + render(b); + + // The pushed view's band and the band of the view below. + expect(rects().filter((entry) => entry === BAND)).toHaveLength(2); + wait(SECURE_TRANSITION_HOLD_MS + 100); + expect(rects()).toEqual([px(selfRect).join(), px(selfRect).join()]); + }); + + it('a pop covers the view on the screen it uncovers, and the view below joins every cover it is given', () => { + const { a, b } = stackOf(); + existing(a); + existing(b); + render(a); + const top = render(b); + + deleting(ancestorOfType(b, SCREEN_HOST_TYPE)); + unmount(top); + // Told twice (as two views on the popped screen would tell it): one cover, joined. + expect(rects().filter((entry) => entry === BAND)).toHaveLength(2); + }); + + it('a view on the screen below that is not on screen is told, and covers nothing', () => { + const { a, b } = stackOf(); + existing(a); + existing(b); + render(a); + const top = render(b); + // Scrolled away (as a hidden view would be): nothing seen. + ancestorOfType(a, SCREEN_HOST_TYPE).memoizedProps = { activityState: 0 }; + wait(SECURE_SHRINK_HOLD_MS + 200); + native.reset(); + + deleting(ancestorOfType(b, SCREEN_HOST_TYPE)); + unmount(top); + + expect(rects().filter((entry) => entry === BAND)).toHaveLength(1); + }); +}); + +describe('P2-6: a screen with no secure view of its own pushed or popped over one with', () => { + /** The view on the first screen of a stack; `more`: further screens on it, with no secure view. */ + function belowOf(more: FiberSpec[] = []): FiberLike { + const below = selfIn( + root([ + fabricHost('RNSScreenStack', null, { children: 1 }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()])]), + ...more, + ]), + ]), + ); + return existing(below); + } + + /** Commits `screens` as the stack's screens, after the first, as a push or pop would. */ + function commitScreens(below: FiberLike, screens: FiberSpec[]): void { + const stack = ancestorOfType(below, 'RNSScreenStack'); + const first = stack.child as FiberLike; + first.sibling = null; + let last = first; + for (const spec of screens) { + const fiber = { ...fabricHostFiber(spec), return: stack, sibling: null } as FiberLike; + last.sibling = fiber; + last = fiber; + } + // A new children prop: the stack re-rendered. + stack.memoizedProps = { children: screens.length + 1 }; + } + + const fabricHostFiber = (spec: FiberSpec): Partial => ({ + tag: spec.tag, + type: spec.type, + memoizedProps: spec.memoizedProps, + stateNode: spec.stateNode, + child: null, + }); + + const pushedScreen = (props: Record = {}): FiberSpec => + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2, ...props }, []); + + it('a push covers the view below, within a frame, for the transition hold', () => { + const below = belowOf(); + render(below); + // To 212 ms: past the tick at 200, so only the stack's wake can measure it within the next frame. + wait(212); + + commitScreens(below, [pushedScreen()]); + wait(17); + + // The default animation: the whole screen below. + expect(firstPublished()).toEqual(px(SCREEN)); + wait(SECURE_TRANSITION_HOLD_MS + 100); + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('a pop covers the view it uncovers, by the popped screen\'s animation', () => { + const below = belowOf([pushedScreen({ stackAnimation: 'slide_from_left' })]); + render(below); + wait(212); + + commitScreens(below, []); + wait(17); + + expect(firstPublished()).toEqual(px(rect(0, 100, 400, 50))); + }); + + it('a stack that re-renders with the same screens covers nothing', () => { + const below = belowOf([pushedScreen()]); + render(below); + wait(212); + + ancestorOfType(below, 'RNSScreenStack').memoizedProps = { children: 2 }; + wait(17); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('reads the first screens without covering anything, or warning', () => { + render(belowOf([pushedScreen()])); + wait(212); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('reads the committed stack, not a stale one the view was mounted under', () => { + const below = belowOf(); + const stack = ancestorOfType(below, 'RNSScreenStack'); + pairWith(stack, { children: 1 }); + render(below); + wait(212); + + // Pushed in front, on the committed stack fiber only. + const pushed = { ...fabricHostFiber(pushedScreen()), return: stack, sibling: stack.child } as FiberLike; + stack.child = pushed; + stack.memoizedProps = { children: 2 }; + wait(17); + + expect(firstPublished()).toEqual(px(SCREEN)); + }); + + it('R3-3: a push nobody saw (it was disabled), found as it is enabled again, is taken as coming then', () => { + const below = belowOf(); + const testRenderer = render(below); + wait(212); + act(() => testRenderer.update()); + + commitScreens(below, [pushedScreen()]); + wait(SECURE_TRANSITION_HOLD_MS + 100); + act(() => testRenderer.update()); + + // When it came is not known: covered for one hold from now, as for a screen first seen. + expect(firstPublished()).toEqual(px(SCREEN)); + wait(SECURE_TRANSITION_HOLD_MS + 100); + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('on iOS, a card presented over it scales the screen below: the whole screen', () => { + mockPlatform.OS = 'ios'; + try { + const below = belowOf(); + render(below); + wait(212); + + commitScreens(below, [pushedScreen({ stackPresentation: 'formSheet' })]); + wait(17); + + // iOS takes points. + expect((lastPublished() as [number, number[]])[1].slice(0, 4)).toEqual([0, 0, 400, 800]); + } finally { + mockPlatform.OS = 'android'; + } + }); + + it('on iOS, a card presented by a view of its own tells the screen below the same', () => { + mockPlatform.OS = 'ios'; + try { + const tree = root([ + fabricHost('RNSScreenStack', null, {}, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()])]), + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2, stackPresentation: 'formSheet' }, [ + view([self()]), + ]), + ]), + ]); + const selves = selvesIn(tree); + const low = selves.find((fiber) => ancestorOfType(fiber, SCREEN_HOST_TYPE).sibling !== null) as FiberLike; + const card = selves.find((fiber) => fiber !== low) as FiberLike; + existing(low); + render(low); + const pushed = ancestorOfType(card, SCREEN_HOST_TYPE); + for (let current: FiberLike | null = pushed; current !== null; current = current.child) { + current.alternate = null; + } + native.reset(); + + render(card); + + // The view below: its whole screen; the card's own view: its column, from where it is down. + const published = (lastPublished() as [number, number[]])[1]; + const rects: string[] = []; + for (let index = 0; index < published.length; index += 4) { + rects.push(published.slice(index, index + 4).join()); + } + expect(rects).toContain([0, 0, 400, 800].join()); + } finally { + mockPlatform.OS = 'android'; + } + }); + + it('screens below wrappers (a stack item) are found too', () => { + const below = belowOf(); + render(below); + wait(212); + + commitScreens(below, [composite([pushedScreen()])]); + const item = ancestorOfType(below, 'RNSScreenStack').child?.sibling as FiberLike; + const screen = { ...fabricHostFiber(pushedScreen()), return: item, sibling: null } as FiberLike; + item.child = screen; + wait(17); + + expect(firstPublished()).toEqual(px(SCREEN)); + }); + + it('a screen pushed over it while it is still being pushed keeps it covered for the hold from that push', () => { + const below = selfIn( + root([ + fabricHost('RNSScreenStack', null, { children: 1 }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()])]), + ]), + ]), + ); + // Its own screen is pushed by the commit that mounts it. + existingFrom(ancestorOfType(below, 'RNSScreenStack')); + render(below); + expect(firstPublished()).toEqual(px(SCREEN)); + wait(400); + + commitScreens(below, [pushedScreen()]); + wait(17); + // Past its own push's hold (800 ms from the mount), inside the second push's (800 ms from it). + wait(SECURE_TRANSITION_HOLD_MS - 300); + expect(firstPublished()).toEqual(px(SCREEN)); + + wait(400); + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); + +describe('N3: content that mounts after its screen or Modal came, still in the transition', () => { + /** A stack of a screen below and a second screen, `b`'s view on the second; `a`'s on the first. */ + function twoScreens(): { a: FiberLike; b: FiberLike; stack: FiberLike; first: FiberLike; second: FiberLike } { + const tree = root([ + fabricHost('RNSScreenStack', null, { children: 2 }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()])]), + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()])]), + ]), + ]); + const selves = selvesIn(tree); + const stack = ancestorOfType(selves[0] as FiberLike, 'RNSScreenStack'); + const first = stack.child as FiberLike; + const second = first.sibling as FiberLike; + const on = (screen: FiberLike): FiberLike => + selves.find((fiber) => ancestorOfType(fiber, SCREEN_HOST_TYPE) === screen) as FiberLike; + return { a: on(first), b: on(second), stack, first, second }; + } + + it('a screen pushed with a spinner: its secure content, mounting 200 ms later, is carried in', () => { + const { b } = twoScreens(); + // The push commit had no secure view; the screen has rendered again since. + unseen(b); + wait(200); + + render(b); + + // The default animation: the whole screen, for the hold. + expect(firstPublished()).toEqual(px(SCREEN)); + wait(SECURE_TRANSITION_HOLD_MS - 100); + expect(firstPublished()).toEqual(px(SCREEN)); + wait(200); + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('seen pushed by a view on the screen below, the late content is carried in from the push, not its mount', () => { + const { a, b, stack, first, second } = twoScreens(); + // Sliding up: the screen below stays put, so only the pushed screen's own view covers its column. + second.memoizedProps = { activityState: 2, stackAnimation: 'slide_from_bottom' }; + // Not pushed yet. + first.sibling = null; + stack.memoizedProps = { children: 1 }; + existing(a); + unseen(b); + render(a); + wait(212); + + first.sibling = second; + stack.memoizedProps = { children: 2 }; + wait(17); + wait(200); + render(b); + const column = px({ ...selfRect, height: SCREEN.height - selfRect.y }).join(); + const published = (): string => (lastPublished() as [number, number[]])[1].join(); + expect(published()).toContain(column); + + // Past the push's hold (800 ms from it, and the tick that publishes its end), inside one from the mount. + wait(SECURE_TRANSITION_HOLD_MS - 200 + 150); + expect(published()).not.toContain(column); + }); + + it('a slide Modal opened with a spinner: its secure content, mounting 200 ms later, gets the band', () => { + const fiber = unseen(inModal({ animationType: 'slide' })); + const host = ancestorOfType(fiber, 'RCTModalHostView'); + host.alternate = { ...host, alternate: host }; + wait(200); + + render(fiber); + + expect((lastOnSurface() as [number, number, number[]])[2].slice(0, 4)).toEqual( + px({ ...selfRect, height: WHOLE_SURFACE.height - selfRect.y }), + ); + }); +}); + +describe('N4: a screen replaced by another on its stack', () => { + /** The view on a stack's only screen, `props` its screen's. */ + function onOnlyScreen(props: Record): FiberLike { + return existing( + selfIn( + root([ + fabricHost('RNSScreenStack', null, { children: 1 }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2, ...props }, [view([self()])]), + ]), + ]), + ), + ); + } + + /** Replaces the view's screen with a new one with `props`, in one commit, and unmounts the view. */ + function replace(fiber: FiberLike, testRenderer: ReactTestRenderer, props: Record): void { + const stack = ancestorOfType(fiber, 'RNSScreenStack'); + const leaving = ancestorOfType(fiber, SCREEN_HOST_TYPE); + const spec = fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2, ...props }, []); + stack.child = { + tag: spec.tag, + type: spec.type, + memoizedProps: spec.memoizedProps, + stateNode: spec.stateNode, + child: null, + sibling: null, + return: stack, + alternate: null, + } as FiberLike; + stack.memoizedProps = { children: 1 }; + deleting(leaving); + unmount(testRenderer); + } + + it('a login screen set to fade, replaced by a Home that slides in, is covered by the slide\'s band', () => { + const fiber = onOnlyScreen({ stackAnimation: 'fade' }); + const testRenderer = render(fiber); + wait(212); + // Whichever fiber of the stack's pair the commit has: here, with no other. + ancestorOfType(fiber, 'RNSScreenStack').alternate = null; + + replace(fiber, testRenderer, { stackAnimation: 'slide_from_right' }); + + expect(firstPublished()).toEqual(px(rect(0, 100, 400, 50))); + wait(SECURE_TRANSITION_HOLD_MS - 100); + expect(firstPublished()).toEqual(px(rect(0, 100, 400, 50))); + }); + + it('replaced by a screen with the default animation (not known): the whole screen', () => { + const fiber = onOnlyScreen({ stackAnimation: 'none' }); + const testRenderer = render(fiber); + wait(212); + + replace(fiber, testRenderer, {}); + + expect(firstPublished()).toEqual(px(SCREEN)); + }); + + it('replaced by a screen sliding up over it: as the screen below, it stays put', () => { + const fiber = onOnlyScreen({ stackAnimation: 'fade' }); + const testRenderer = render(fiber); + wait(212); + + replace(fiber, testRenderer, { stackAnimation: 'slide_from_bottom' }); + + expect(firstPublished()).toEqual(px(selfRect)); + }); + + it('on iOS, replaced by a card presented over it: the whole screen (the card scales it)', () => { + mockPlatform.OS = 'ios'; + try { + const fiber = onOnlyScreen({ stackAnimation: 'fade' }); + const testRenderer = render(fiber); + wait(212); + + replace(fiber, testRenderer, { stackPresentation: 'formSheet' }); + + // iOS takes points. + expect((lastPublished() as [number, number[]])[1].slice(0, 4)).toEqual([0, 0, 400, 800]); + } finally { + mockPlatform.OS = 'android'; + } + }); + + it('replaced while nothing of it is on screen: nothing to cover, nothing thrown', () => { + // Scrolled out of its ScrollView: seen nowhere. + selfRect = rect(10, 500, 200, 50); + const fiber = existing( + selfIn( + root([ + fabricHost('RNSScreenStack', null, { children: 1 }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2, stackAnimation: 'fade' }, [ + scrollView(rect(0, 0, 400, 300), [view([self()])]), + ]), + ]), + ]), + ), + ); + const testRenderer = render(fiber); + wait(212); + + replace(fiber, testRenderer, {}); + + expect(console.warn).not.toHaveBeenCalled(); + // Nothing on screen to publish, if anything at all. + const published = (lastPublished() as [number, number[]] | undefined)?.[1] ?? []; + expect(published.join()).not.toContain(px(SCREEN).join()); + }); + + it('a cover run out but not yet dropped (between ticks) is not joined as it departs', () => { + const tree = root([ + fabricHost('RNSScreenStack', null, { children: 1 }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2, stackAnimation: 'fade' }, [view([self()])]), + ]), + ]); + const fiber = existing(selvesIn(tree)[0] as FiberLike); + const testRenderer = render(fiber); + wait(212); + const stack = ancestorOfType(fiber, 'RNSScreenStack'); + const spec = fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, []); + (stack.child as FiberLike).sibling = { + tag: spec.tag, + type: spec.type, + memoizedProps: spec.memoizedProps, + stateNode: spec.stateNode, + child: null, + sibling: null, + return: stack, + } as FiberLike; + stack.memoizedProps = { children: 2 }; + wait(17); + expect(firstPublished()).toEqual(px(SCREEN)); + // The push's cover ran out at ~1029; the next tick, which drops it, is at 1100. + wait(1050 - 229); + + deleting(ancestorOfType(fiber, SCREEN_HOST_TYPE)); + unmount(testRenderer); + + expect(firstPublished()).toEqual(px(selfRect)); + }); + + it('popped alone, nothing new on its stack: its own animation', () => { + const fiber = onOnlyScreen({ stackAnimation: 'fade' }); + const testRenderer = render(fiber); + wait(212); + + deleting(ancestorOfType(fiber, SCREEN_HOST_TYPE)); + unmount(testRenderer); + + // Its fade's cover (the view where it rests), and the view held. + expect(lastPublished()).toEqual([0, [...px(selfRect), ...px(selfRect)]]); + }); + + it('a cover still running is joined, not replaced: the later end kept', () => { + // On a fading screen below; a push over it covers it whole, then its screen is removed (a reset). + const tree = root([ + fabricHost('RNSScreenStack', null, { children: 1 }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2, stackAnimation: 'fade' }, [view([self()])]), + ]), + ]); + const fiber = existing(selvesIn(tree)[0] as FiberLike); + const testRenderer = render(fiber); + wait(212); + const stack = ancestorOfType(fiber, 'RNSScreenStack'); + const spec = fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, []); + (stack.child as FiberLike).sibling = { + tag: spec.tag, + type: spec.type, + memoizedProps: spec.memoizedProps, + stateNode: spec.stateNode, + child: null, + sibling: null, + return: stack, + } as FiberLike; + stack.memoizedProps = { children: 2 }; + wait(17); + expect(firstPublished()).toEqual(px(SCREEN)); + wait(200); + + deleting(ancestorOfType(fiber, SCREEN_HOST_TYPE)); + unmount(testRenderer); + + expect(firstPublished()).toEqual(px(SCREEN)); + // The push's hold has run out (800 ms from it); the removal's has not. + wait(SECURE_TRANSITION_HOLD_MS - 150); + expect(firstPublished()).toEqual(px(SCREEN)); + }); +}); + +describe('P3-g: a Modal opened over a stack screen', () => { + // A Modal is a root of its own: the screens it was opened over are not the view's carriers (as on c812b12). + it('a push on that stack, under the Modal, covers nothing on the Modal', () => { + const fiber = existing( + selfIn( + root([ + fabricHost('RNSScreenStack', null, { children: 1 }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [ + view([ + { + tag: FiberTag.HostComponent, + type: 'RCTModalHostView', + memoizedProps: { animationType: 'slide' }, + stateNode: { rect: null, tag: MODAL_TAG, canonical: { currentProps: {} } }, + children: [fabricHost('RCTView', rect(0, 0, 400, 800), {}, [view([self()])])], + }, + ]), + ]), + ]), + ]), + ), + ); + render(fiber); + wait(212); + const stack = ancestorOfType(fiber, 'RNSScreenStack'); + const spec = fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, []); + (stack.child as FiberLike).sibling = { + tag: spec.tag, + type: spec.type, + memoizedProps: spec.memoizedProps, + stateNode: spec.stateNode, + child: null, + sibling: null, + return: stack, + } as FiberLike; + stack.memoizedProps = { children: 2 }; + wait(17); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + }); +}); + +describe('P2-8: the cover follows the screen\'s stackAnimation', () => { + function pushed(stackAnimation: unknown, extra: Record = {}): void { + const fiber = selfIn( + root([ + fabricHost('RNSScreenStack', null, {}, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2, stackAnimation, ...extra }, [view([self()])]), + ]), + ]), + ); + existingFrom(ancestorOfType(fiber, 'RNSScreenStack')); + render(fiber); + } + + it.each([ + ['slide_from_right', { x: 0, y: 100, width: 400, height: 50 }], + ['ios_from_left', { x: 0, y: 100, width: 400, height: 50 }], + ['slide_from_bottom', { x: 10, y: 100, width: 200, height: 700 }], + ['fade', { x: 10, y: 100, width: 200, height: 50 }], + ['default', SCREEN], + ['flip', SCREEN], + [undefined, SCREEN], + ])('%s', (animation, expected) => { + pushed(animation); + + expect(firstPublished()).toEqual(px(expected)); + }); + + it('a modal presentation with the default animation on Android covers the whole screen', () => { + pushed('default', { stackPresentation: 'modal' }); + + expect(firstPublished()).toEqual(px(SCREEN)); + }); + + it('a view cut away by a ScrollView on the screen covers nothing more as it moves', () => { + const fiber = selfIn( + root([ + fabricHost('RNSScreenStack', null, {}, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2, stackAnimation: 'slide_from_right' }, [ + scrollView(rect(0, 400, 400, 100), [self()]), + ]), + ]), + ]), + ); + existingFrom(ancestorOfType(fiber, 'RNSScreenStack')); + render(fiber); + + // Nothing published at all: the lane already holds nothing. + expect(native.setSecureRectangles.mock.calls.every(([, flat]) => (flat as number[]).length === 0)).toBe(true); + }); +}); + +describe('P2-5: views that come later into a transition still running', () => { + it('a view mounted on a pushed screen a commit later, within the hold, is carried in until the hold ends', () => { + const tree = root([ + fabricHost('RNSScreenStack', null, {}, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()]), view([self()])]), + ]), + ]); + const [first, later] = selvesIn(tree) as [FiberLike, FiberLike]; + existingFrom(ancestorOfType(first, 'RNSScreenStack')); + render(first); + + // 300 ms on, a row rendered in a later batch: the screen has been rendered again since. + wait(300); + existing(later); + native.reset(); + render(later); + // Both views cover the screen: the first from its own push, the later one carried in with it. + const published = (lastPublished() as [number, number[]])[1]; + const screens = published.filter((_, index) => index % 4 === 0).filter((_, index) => + published.slice(index * 4, index * 4 + 4).join() === px(SCREEN).join(), + ); + expect(screens).toHaveLength(2); + + wait(SECURE_TRANSITION_HOLD_MS - 300 + 100); + expect(lastPublished()).toEqual([0, [...px(selfRect), ...px(selfRect)]]); + }); + + it('a view whose first measurement failed still covers its popped screen', () => { + const fiber = existing(onStackScreen()); + measureInWindow.mockImplementationOnce(() => { + throw new Error('not laid out'); + }); + const testRenderer = render(fiber); + expect(native.setSecureRectangles).not.toHaveBeenCalled(); + + deleting(ancestorOfType(fiber, SCREEN_HOST_TYPE)); + unmount(testRenderer); + + expect(lastPublished()).toEqual([0, px(SCREEN)]); + }); +}); + +const SLIDING = { activityState: 2, stackAnimation: 'slide_from_right' }; + +describe('round 2: what only a mounted tracker shows', () => { + it('a view mounted on a screen that was there before is not followed every frame', () => { + render(existing(onStackScreen())); + const before = measureInWindow.mock.calls.length; + + act(() => { + jest.advanceTimersByTime(16 * 5); + }); + + // Five frames, no tick yet: not measured. + expect(measureInWindow.mock.calls.length).toBe(before); + }); + + it('a view hidden as its screen is pushed, shown after the screen rendered again, is carried in', () => { + const fiber = selfIn( + root([ + fabricHost('RNSScreenStack', null, {}, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 0 }, [view([self()])]), + ]), + ]), + ); + existingFrom(ancestorOfType(fiber, 'RNSScreenStack')); + render(fiber); + + // Rendered again (so no longer without an alternate), and shown. + const screen = ancestorOfType(fiber, SCREEN_HOST_TYPE); + existing(fiber); + screen.memoizedProps = { activityState: 2 }; + wait(17); + + expect(firstPublished()).toEqual(px(SCREEN)); + }); + + it('the screen below is told the committed stackAnimation, not one in flight', () => { + const tree = root([ + fabricHost('RNSScreenStack', null, {}, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()])]), + fabricHost(SCREEN_HOST_TYPE, SCREEN, SLIDING, [view([self()])]), + ]), + ]); + const selves = selvesIn(tree); + const below = selves.find((fiber) => ancestorOfType(fiber, SCREEN_HOST_TYPE).sibling !== null) as FiberLike; + const top = selves.find((fiber) => fiber !== below) as FiberLike; + existing(below); + existing(top); + const pushed = ancestorOfType(top, SCREEN_HOST_TYPE); + render(below); + const topRenderer = render(top); + // A render in flight that would change the animation, never committed. + pairWith(pushed.alternate as FiberLike, { activityState: 2 }); + + deleting(pushed); + unmount(topRenderer); + + const published = (lastPublished() as [number, number[]])[1]; + expect(published.slice(0, 4)).toEqual(px(rect(0, 100, 400, 50))); + }); + + it('a parked view below a push drops the cover it was given once it runs out', () => { + const tree = root([ + fabricHost('RNSScreenStack', null, {}, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [scrollView(rect(0, 0, 400, 120), [self()])]), + fabricHost(SCREEN_HOST_TYPE, SCREEN, SLIDING, [view([self()])]), + ]), + ]); + const selves = selvesIn(tree); + const below = selves.find((fiber) => ancestorOfType(fiber, SCREEN_HOST_TYPE).sibling !== null) as FiberLike; + const top = selves.find((fiber) => fiber !== below) as FiberLike; + existing(below); + render(below); + wait(16 * 12); + // Parked, clipped to 100..120; then the other screen is pushed over it. + const pushed = ancestorOfType(top, SCREEN_HOST_TYPE); + for (let current: FiberLike | null = pushed; current !== null; current = current.child) { + current.alternate = null; + } + render(top); + wait(SECURE_TRANSITION_HOLD_MS + 200); + + const published = (lastPublished() as [number, number[]])[1]; + expect(published).toEqual([...px(rect(10, 100, 200, 20)), ...px(selfRect)]); + }); + + it('a native value replaced by another in a commit is watched instead', () => { + const a = animatedValue(0); + const b = animatedValue(0); + const fiber = underAnimated([{ translateX: a }, { translateY: b }], [{ translateX: 0 }, { translateY: 0 }]); + render(fiber); + wait(16 * 12); + + const c = animatedValue(0); + const wrapper = ancestorOfType(fiber, AnimatedView); + wrapper.memoizedProps = { style: { transform: [{ translateX: a }, { translateY: c }] } }; + // To 242 ms: the tick at 200 measures it with c. + wait(50); + c._animation = timing(0, 100); + // To 259 ms: no tick; only the watch on c can wake it. + wait(17); + + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 150))); + }); +}); + +describe('C3: a Modal sliding in and out', () => { + it('a Modal mounted with the whole tree (open as the app starts) is not carried', () => { + const fiber = inModal({ animationType: 'slide' }); + for (let current = fiber.return; current !== null; current = current.return) { + current.alternate = null; + } + render(fiber); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + }); + + it('a view not laid out yet covers no band; the band follows it once it is', () => { + selfRect = rect(0, 0, 0, 0); + render(inModal({ animationType: 'slide' })); + // Nothing to publish: the lane stays as the last test left it, empty. + expect(native.setSecureRectanglesOnSurface).not.toHaveBeenCalled(); + + selfRect = rect(10, 100, 200, 50); + wait(17); + + expect((lastOnSurface() as [number, number, number[]])[2].slice(0, 4)).toEqual(px(band(selfRect))); + }); + + // The band below the view, as far as any surface reaches. + const band = (at: WindowRect): WindowRect => ({ ...at, height: WHOLE_SURFACE.height - at.y }); + + it('covers the band it slides in along as it opens, then only the view', () => { + render(inModal({ animationType: 'slide' })); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(band(selfRect)), ...px(selfRect)]]); + wait(SECURE_TRANSITION_HOLD_MS + 100); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + }); + + it('follows the content laid out again once the dialog is up, within a frame', () => { + render(inModal({ animationType: 'slide' })); + + // The dialog reports its real size: the content is centred lower. + const before = selfRect; + selfRect = rect(10, 163, 200, 50); + wait(17); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(band(before)), ...px(selfRect), ...px(before)]]); + }); + + it('a band that moves as the content is laid out again covers both, joined', () => { + render(inModal({ animationType: 'slide' })); + + const before = selfRect; + selfRect = rect(5, 60, 200, 50); + wait(17); + + // From the left of the second to the right of the first, from the top of the second down. + const joined = { x: 5, y: 60, width: 205, height: WHOLE_SURFACE.height - 60 }; + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(joined), ...px(selfRect), ...px(before)]]); + + // And again, further right: the join grows to its right edge. + selfRect = rect(40, 60, 200, 50); + wait(17); + const wider = { ...joined, width: 235 }; + expect((lastOnSurface() as [number, number, number[]])[2].slice(0, 4)).toEqual(px(wider)); + }); + + it('a fade Modal opening covers only the view, still followed every frame', () => { + render(inModal({ animationType: 'fade' })); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + + const before = selfRect; + selfRect = rect(10, 163, 200, 50); + wait(17); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(selfRect), ...px(before)]]); + // Carried with no cover from it: nothing to join, nothing thrown. + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('a view first laid out with no width, elsewhere, joins nothing of there into its band', () => { + selfRect = rect(300, 100, 0, 50); + render(inModal({ animationType: 'slide' })); + selfRect = rect(10, 100, 200, 50); + wait(17); + + expect((lastOnSurface() as [number, number, number[]])[2].slice(0, 4)).toEqual(px(band(selfRect))); + }); + + it('a view carried in while cut away by a ScrollView in it covers nothing, and throws nothing', () => { + selfRect = rect(10, 500, 200, 50); + const fiber = selfIn( + root([ + view([ + { + tag: FiberTag.HostComponent, + type: 'RCTModalHostView', + memoizedProps: { animationType: 'slide' }, + stateNode: { rect: null, tag: MODAL_TAG, canonical: { currentProps: {} } }, + children: [ + fabricHost('RCTView', rect(0, 0, 400, 800), {}, [scrollView(rect(0, 0, 400, 300), [view([self()])])]), + ], + }, + ]), + ]), + ); + existingFrom(ancestorOfType(ancestorOfType(fiber, 'RCTModalHostView'), 'RCTView')); + render(fiber); + wait(17); + + expect(console.warn).not.toHaveBeenCalled(); + const published = (lastOnSurface() as [number, number, number[]] | undefined)?.[2] ?? []; + expect(published).toEqual([]); + }); + + it('a stack inside a fading Modal opening covers the screen, and nothing for the Modal', () => { + const fiber = onScreenInModal('RNSScreenStack', 2, { animationType: 'fade', visible: true }, true); + const host = ancestorOfType(fiber, 'RCTModalHostView'); + for (let current: FiberLike | null = host; current !== null; current = current.child) { + current.alternate = null; + } + render(fiber); + + expect(console.warn).not.toHaveBeenCalled(); + expect((lastOnSurface() as [number, number, number[]])[2].slice(0, 4)).toEqual(px(SCREEN)); + }); + + it.each<[string, WindowRect]>([ + ['no width', rect(10, 100, 0, 50)], + ['no height', rect(10, 100, 200, 0)], + ])('a view laid out with %s while carried covers no band', (_, laidOut) => { + selfRect = laidOut; + render(inModal({ animationType: 'slide' })); + wait(17); + + const published = (lastOnSurface() as [number, number, number[]] | undefined)?.[2] ?? []; + expect(published.join()).not.toContain(px(band(laidOut)).join()); + }); + + it('a view mounted in a Modal that was already open is not carried', () => { + render(existing(inModal({ animationType: 'slide' }))); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + }); + + it('covers the band it slides out along as it closes', () => { + const fiber = existing(inModal({ animationType: 'slide' })); + const testRenderer = render(fiber); + + deleting(ancestorOfType(fiber, 'RCTModalHostView')); + unmount(testRenderer); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(band(selfRect)), ...px(selfRect)]]); + wait(SECURE_TRANSITION_HOLD_MS - 100); + expect((lastOnSurface() as [number, number, number[]])[2].slice(0, 4)).toEqual(px(band(selfRect))); + wait(200); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, []]); + }); + + it('a fading Modal holds the view where it rests as it closes', () => { + const fiber = existing(inModal({ animationType: 'fade' })); + const testRenderer = render(fiber); + + deleting(ancestorOfType(fiber, 'RCTModalHostView')); + unmount(testRenderer); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + // A Modal has no stack to look for a replacement on. + expect(console.warn).not.toHaveBeenCalled(); + wait(SECURE_SHRINK_HOLD_MS + 100); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, []]); + }); + + it('covers the band from the commit that hides a Modal still mounted (iOS), within a frame', () => { + const fiber = existing(inModal({ animationType: 'slide', visible: true })); + render(fiber); + wait(16 * 20); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + + // visible={false}: the host stays mounted until onDismiss, sliding out. + ancestorOfType(fiber, 'RCTModalHostView').memoizedProps = { animationType: 'slide', visible: false }; + wait(17); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(band(selfRect)), ...px(selfRect)]]); + + // Covered once per close, until the transition hold runs out. + wait(SECURE_TRANSITION_HOLD_MS + 100); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + }); + + it('a Modal hidden while it still slides in keeps the close band for the hold from the hide', () => { + const fiber = inModal({ animationType: 'slide', visible: true }); + render(fiber); + wait(400); + + ancestorOfType(fiber, 'RCTModalHostView').memoizedProps = { animationType: 'slide', visible: false }; + wait(17); + // Past the open's hold (800 ms from the mount), inside the close's (800 ms from the hide). + wait(SECURE_TRANSITION_HOLD_MS - 300); + expect((lastOnSurface() as [number, number, number[]])[2].slice(0, 4)).toEqual(px(band(selfRect))); + + wait(400); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + }); + + /** The view on a screen of a `container` inside a `` with `props`, every ancestor there before. */ + /** `opening`: the Modal and what is in it come with the commit that mounts the view. */ + function onScreenInModal( + container: string, + activityState: number, + props: Record, + opening = false, + ): FiberLike { + const screen = fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState }, [view([self()])]); + return (opening ? unseen : existing)( + selfIn( + root([ + view([ + { + tag: FiberTag.HostComponent, + type: 'RCTModalHostView', + memoizedProps: props, + stateNode: { rect: null, tag: MODAL_TAG, canonical: { currentProps: props } }, + children: [fabricHost('RCTView', rect(0, 0, 400, 800), {}, [fabricHost(container, null, {}, [screen])])], + }, + ]), + ]), + ), + ); + } + + it('a Modal hidden on a still-mounted host is covered even for a view hidden as it mounted', () => { + const fiber = onScreenInModal('RNSScreenContainer', 0, { animationType: 'slide', visible: true }); + render(fiber); + // Its screen shown later: no arrival ran while it was hidden. + const screen = ancestorOfType(fiber, SCREEN_HOST_TYPE); + screen.memoizedProps = { activityState: 2 }; + wait(17); + wait(16 * 20); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + + ancestorOfType(fiber, 'RCTModalHostView').memoizedProps = { animationType: 'slide', visible: false }; + wait(17); + + expect((lastOnSurface() as [number, number, number[]])[2].slice(0, 4)).toEqual(px(band(selfRect))); + }); + + it('a Modal hidden on a still-mounted host is covered for a view on a stack inside it', () => { + const fiber = onScreenInModal('RNSScreenStack', 2, { animationType: 'slide', visible: true }); + render(fiber); + wait(16 * 20); + + ancestorOfType(fiber, 'RCTModalHostView').memoizedProps = { animationType: 'slide', visible: false }; + wait(17); + + expect((lastOnSurface() as [number, number, number[]])[2].slice(0, 4)).toEqual(px(band(selfRect))); + }); + + it('a view mounted in a Modal already closing (iOS) covers the band at once', () => { + render(existing(inModal({ animationType: 'slide', visible: false }))); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(band(selfRect)), ...px(selfRect)]]); + }); + + it('re-enabled while its Modal closes, it covers the band again', () => { + const fiber = existing(inModal({ animationType: 'slide', visible: true })); + const testRenderer = render(fiber); + ancestorOfType(fiber, 'RCTModalHostView').memoizedProps = { animationType: 'slide', visible: false }; + wait(17); + + act(() => testRenderer.update()); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, []]); + act(() => testRenderer.update()); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(band(selfRect)), ...px(selfRect)]]); + }); + + it('a fading Modal hidden while mounted adds no band, and the view is still followed', () => { + const fiber = existing(inModal({ animationType: 'fade', visible: true })); + render(fiber); + ancestorOfType(fiber, 'RCTModalHostView').memoizedProps = { animationType: 'fade', visible: false }; + wait(17); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + + const before = selfRect; + selfRect = rect(10, 300, 200, 50); + wait(100); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(selfRect), ...px(before)]]); + }); + + it('P2-5: a view hidden as a Modal opens, shown while it still slides in, is carried in; shown later, not', () => { + const fiber = onScreenInModal('RNSScreenContainer', 0, { animationType: 'slide', visible: true }, true); + // The Modal was created by the commit that mounted the view; the screen that opened it was there. + const host = ancestorOfType(fiber, 'RCTModalHostView'); + for (let current: FiberLike | null = host; current !== null; current = current.child) { + current.alternate = null; + } + render(fiber); + const screen = ancestorOfType(fiber, SCREEN_HOST_TYPE); + screen.memoizedProps = { activityState: 2 }; + wait(17); + + expect((lastOnSurface() as [number, number, number[]])[2].slice(0, 4)).toEqual(px(band(selfRect))); + + // Hidden, and shown again once the transition is over: no band. + screen.memoizedProps = { activityState: 0 }; + wait(SECURE_TRANSITION_HOLD_MS + 100); + screen.memoizedProps = { activityState: 2 }; + wait(17); + expect(lastOnSurface()).toEqual([0, MODAL_TAG, px(selfRect)]); + }); + + it('a stack inside a slide Modal opening covers the band as well as the screen', () => { + const fiber = onScreenInModal('RNSScreenStack', 2, { animationType: 'slide', visible: true }, true); + // The Modal and everything in it are new; what opened it was there. + const host = ancestorOfType(fiber, 'RCTModalHostView'); + for (let current: FiberLike | null = host; current !== null; current = current.child) { + current.alternate = null; + } + render(fiber); + + const joined = { x: 0, y: 0, width: 400, height: WHOLE_SURFACE.height }; + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(joined), ...px(selfRect)]]); + }); + + it('a stack inside a slide Modal closing covers the band as well as the screen', () => { + const fiber = onScreenInModal('RNSScreenStack', 2, { animationType: 'slide', visible: true }); + const testRenderer = render(fiber); + + deleting(ancestorOfType(fiber, 'RCTModalHostView')); + unmount(testRenderer); + + const joined = { x: 0, y: 0, width: 400, height: WHOLE_SURFACE.height }; + expect(lastOnSurface()).toEqual([0, MODAL_TAG, [...px(joined), ...px(selfRect)]]); + }); + + it('a view in a Modal on no stack reads no stack, and warns about nothing', () => { + render(existing(inModal({ animationType: 'slide' }))); + wait(212); + + expect(console.warn).not.toHaveBeenCalled(); + }); + + it('a Modal without animation holds only the view as it closes', () => { + const fiber = existing(inModal({ animationType: 'none' })); + const testRenderer = render(fiber); + + deleting(ancestorOfType(fiber, 'RCTModalHostView')); + unmount(testRenderer); + wait(SECURE_SHRINK_HOLD_MS + 100); + + expect(lastOnSurface()).toEqual([0, MODAL_TAG, []]); + }); +}); + +/** A stand-in for an `Animated.Value`. */ +interface FakeValue { + __isNative: boolean; + _value: number; + _offset: number; + _animation: { _fromValue: number; _toValue: number; _easing: (t: number) => number } | null; + _tracking: null; + __getValue: () => number; + hasListeners: () => boolean; +} + +/** + * `known`: made native after the package loaded, so what drives it is known; + * otherwise (made native before, or by what is not followed) anything may. + */ +function animatedValue(value: number, isNative = true, known = true): FakeValue { + const node: FakeValue = { + __isNative: isNative, + _value: value, + _offset: 0, + _animation: null, + _tracking: null, + __getValue: () => node._value + node._offset, + hasListeners: () => known, + }; + if (!known) { + return node; + } + // Made native as an app's would be: by its first animation or event with the native driver. + Object.setPrototypeOf(node, FakeAnimatedValue.prototype); + return isNative ? madeNative(node) : node; +} + +const linear = (t: number): number => t; +const timing = (from: number, to: number): FakeValue['_animation'] => ({ + _fromValue: from, + _toValue: to, + _easing: linear, +}); + +/** A stand-in for `value.interpolate({ inputRange, outputRange, ...extra })`, extending past its ends. */ +function interpolation( + parent: object, + inputRange: number[], + outputRange: number[], + extra: Record = {}, +): object { + const interpolate = (input: number): number => { + const [i0, i1] = inputRange as [number, number]; + const [o0, o1] = outputRange as [number, number]; + return o0 + ((input - i0) * (o1 - o0)) / (i1 - i0); + }; + return { + __isNative: true, + _parent: parent, + _config: { inputRange, outputRange, ...extra }, + _getInterpolation: () => interpolate, + __getValue: () => interpolate((parent as FakeValue).__getValue()), + }; +} + +const AnimatedView = Object.assign(() => null, { displayName: 'Animated(View)' }); + +/** + * The view under an `Animated.View` styled `transform` (and `extra`), which + * renders the host `RCTView` measured at `animatedRect` with `rendered` as + * its transform. + */ +function underAnimated( + transform: Array>, + rendered: Array>, + animatedRect = rect(0, 0, 400, 800), + outer: (spec: FiberSpec) => FiberSpec = (spec) => spec, + inner: (spec: FiberSpec) => FiberSpec = (spec) => spec, + extra: Record = {}, +): FiberLike { + return existing( + selfIn( + root([ + outer({ + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform, ...extra } }, + children: [ + fabricHost('RCTView', animatedRect, { style: { transform: rendered, ...extra } }, [inner(view([self()]))]), + ], + }), + ]), + ), + ); +} + +/** The first rectangle of the last publish on display 0. */ +const firstPublished = (): number[] => (lastPublished() as [number, number[]])[1].slice(0, 4); + +/** Every rectangle of the last publish on display 0, each as [left, top, right, bottom]. */ +function publishedRects(): number[][] { + const flat = (lastPublished() as [number, number[]] | undefined)?.[1] ?? []; + return Array.from({ length: flat.length / 4 }, (_, index) => flat.slice(4 * index, 4 * index + 4)); +} + +describe('B6: a native-driven Animated transform above the view', () => { + it('covers every place an interpolated translateX can draw it', () => { + const value = animatedValue(0); + value._animation = timing(0, 1); + render(underAnimated([{ translateX: interpolation(value, [0, 1], [0, 160]) }], [{ translateX: 0 }])); + + expect(lastPublished()).toEqual([0, px(rect(10, 100, 360, 50))]); + }); + + it('covers a value stopped part-way, whichever value the shadow tree holds', () => { + const value = animatedValue(0); + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + + // Stopped at 40: synced back to JS; the shadow tree holds 0 (or 40, with C++ Animated). + value._value = 40; + wait(100); + + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 90))); + }); + + it('P1-1: with the end value in the shadow tree and the stale one in props, the mask stays on the view', () => { + // C++ Animated: the card slid from 300 to 0; native committed 0 to the shadow tree; props still say 300. + const value = animatedValue(0); + render(underAnimated([{ translateY: value }], [{ translateY: 300 }])); + + const published = firstPublished(); + // The view, where the shadow tree (and native) have it, is covered... + expect(published[1]).toBeLessThanOrEqual(2 * selfRect.y); + expect(published[3]).toBeGreaterThanOrEqual(2 * (selfRect.y + selfRect.height)); + // ...and so is where it would be with the props' value in the shadow tree. + expect(published[1]).toBe(2 * (selfRect.y - 300)); + }); + + it('covers a running timing animation from end to end, and follows it every frame', () => { + const value = animatedValue(0); + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + const measures = measureInWindow.mock.calls.length; + + value._animation = timing(0, 100); + wait(17); + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 150))); + + // Running: measured every frame, however still the shadow tree is. + wait(16 * 20); + expect(measureInWindow.mock.calls.length - measures).toBeGreaterThanOrEqual(20); + }); + + it('P2-3: a still native transform measures nothing per frame, and wakes the frame an animation starts', () => { + const value = animatedValue(0); + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + wait(16 * 12); + const before = measureInWindow.mock.calls.length; + + // Six frames, no tick: nothing measured. + act(() => { + jest.advanceTimersByTime(16 * 5); + }); + expect(measureInWindow.mock.calls.length).toBeLessThanOrEqual(before + 1); + + value._animation = timing(0, 100); + const started = measureInWindow.mock.calls.length; + wait(17); + expect(measureInWindow.mock.calls.length).toBeGreaterThan(started); + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 150))); + }); + + it('N1: a clipped view whose value is not native yet is covered from the frame its first native slide starts', () => { + // A list row partly scrolled out (so parked), swiped away on a fresh value. + const value = animatedValue(0, false); + render( + underAnimated([{ translateY: value }], [{ translateY: 0 }], undefined, (spec) => + scrollView(rect(0, 0, 400, 120), [spec]), + ), + ); + wait(16 * 20); + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 20))); + + // `start()` with the native driver: made native, and the animation set; nothing committed. + (value as unknown as FakeAnimatedValue).__makeNative(); + value._animation = timing(0, -100); + wait(17); + + expect(firstPublished()).toEqual(px(rect(10, 0, 200, 120))); + }); + + it('N1: a wholly visible view whose value is not native yet is covered within a frame, not a tick', () => { + const value = animatedValue(0, false); + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + // To 212 ms: past the tick at 200, so only the value's watch can measure it within the next frame. + wait(212); + + (value as unknown as FakeAnimatedValue).__makeNative(); + value._animation = timing(0, -100); + wait(17); + + expect(firstPublished()).toEqual(px(rect(10, 0, 200, 150))); + }); + + it('P2-3: under an Animated component with no native transform, a view parks like any other', () => { + render( + underAnimated([{ translateX: 5 }], [{ translateX: 5 }], undefined, undefined, (spec) => + scrollView(rect(0, 0, 400, 120), [spec]), + ), + ); + wait(16 * 12); + const before = measureInWindow.mock.calls.length; + + act(() => { + jest.advanceTimersByTime(16 * 5); + }); + + // Parked: only the tick's own check (at 200 ms) measures it. + expect(measureInWindow.mock.calls.length).toBe(before + 1); + }); + + it('P1-2: a native value nobody animated from JS (an Animated.event) covers the whole surface, clipped', () => { + const scrollY = animatedValue(0, true, false); + render( + underAnimated([{ translateY: scrollY }], [{ translateY: 0 }], undefined, (spec) => + scrollView(rect(0, 50, 400, 300), [spec]), + ), + ); + + expect(firstPublished()).toEqual(px(rect(0, 50, 400, 300))); + }); + + it('P1-2: a collapsing header (diffClamp, clamped interpolation) is covered over its whole travel', () => { + const scrollY = animatedValue(0, true, false); + const clamp = { __isNative: true, _a: scrollY, _min: 0, _max: 60, _value: 0, __getValue: () => 0 }; + const header = interpolation(clamp, [0, 60], [0, -60], { extrapolate: 'clamp' }); + render(underAnimated([{ translateY: header }], [{ translateY: 0 }])); + + expect(firstPublished()).toEqual(px(rect(10, 40, 200, 110))); + }); + + it('P1-2: a clamped interpolation of a value it cannot bound is covered over its output range', () => { + const scrollY = animatedValue(0, true, false); + render( + underAnimated( + [ + { + translateY: interpolation(scrollY, [0, 100], [0, -30], { + extrapolateLeft: 'clamp', + extrapolateRight: 'clamp', + }), + }, + ], + [{ translateY: 0 }], + ), + ); + + expect(firstPublished()).toEqual(px(rect(10, 70, 200, 80))); + }); + + it('P2-1: a value whose fields were renamed is unbounded: the whole surface', () => { + const value = animatedValue(0) as unknown as Record; + value._runningAnimation = value._animation; + delete value._animation; + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + + expect(firstPublished()).toEqual(px(WHOLE_SURFACE)); + }); + + it('includes what an easing overshoots', () => { + const value = animatedValue(0); + value._animation = { _fromValue: 0, _toValue: 100, _easing: (t) => t * (1.2 - 0.2 * t) * (t < 0.5 ? 1 : 1.25) }; + render(underAnimated([{ translateX: value }], [{ translateX: 0 }])); + + expect(firstPublished()[2]).toBeGreaterThan(2 * (10 + 200 + 100)); + }); + + it('scales about the animated view’s centre', () => { + const value = animatedValue(2); + render(underAnimated([{ scale: value }], [{ scale: 1 }], rect(0, 0, 400, 400))); + + // Twice the size about (200, 200): x 10..210 to -180..220, y 100..150 to 0..100; and where it rests. + expect(firstPublished()).toEqual(px(rect(-180, 0, 400, 150))); + }); + + it('P2-2: scales about its transformOrigin', () => { + const value = animatedValue(1); + value._animation = timing(1, 1.5); + render(underAnimated([{ scale: value }], [{ scale: 1 }], rect(0, 0, 400, 400), undefined, undefined, { + transformOrigin: 'top left', + })); + + // About (0, 0), by 1 to 1.5: x 10..315, y 100..225. + expect(firstPublished()).toEqual(px(rect(10, 100, 305, 125))); + }); + + it('P2-2: a transformOrigin it cannot read is unbounded', () => { + const value = animatedValue(1); + value._animation = timing(1, 1.5); + render(underAnimated([{ scale: value }], [{ scale: 1 }], rect(0, 0, 400, 400), undefined, undefined, { + transformOrigin: 'calc(50% - 2px) top', + })); + + expect(firstPublished()).toEqual(px(WHOLE_SURFACE)); + }); + + it('P2-4: composes a translation with a scale, and adds two bounded values', () => { + const lift = animatedValue(0); + lift._animation = timing(0, -20); + const sum = { __isNative: true, _a: lift, _b: animatedValue(10), __getValue: () => lift.__getValue() + 10 }; + const zoom = animatedValue(1); + zoom._animation = timing(1, 1.1); + render( + underAnimated([{ translateY: sum }, { scale: zoom }], [{ translateY: 10 }, { scale: 1 }], rect(0, 0, 400, 400)), + ); + + const [left, top, right, bottom] = firstPublished(); + // Never the whole surface; reaches 20 up (the sum from 10 down to -10) and the 10% growth about the centre. + expect(right! - left!).toBeLessThan(2 * 400); + expect(top).toBeLessThanOrEqual(2 * (100 - 20 - 10)); + expect(bottom).toBeGreaterThanOrEqual(2 * 150); + }); + + it('P2-4: clips by a clipping ancestor inside the transform before moving, and by one above after', () => { + const value = animatedValue(0); + value._animation = timing(0, 1); + render( + underAnimated( + [{ translateY: interpolation(value, [0, 1], [0, 300]) }], + [{ translateY: 0 }], + undefined, + (spec) => scrollView(rect(0, 0, 400, 1000), [spec]), + (spec) => scrollView(rect(0, 0, 400, 130), [spec]), + ), + ); + + // Cut to 100..130 inside, then moved by up to 300: 100..430 (not 100..450), within 0..1000 above. + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 330))); + }); + + it('a rotation stays unbounded: the whole surface, cut by a clipping ancestor above', () => { + render( + underAnimated([{ rotate: animatedValue(1) }], [{ rotate: '0deg' }], undefined, (spec) => + scrollView(rect(0, 50, 400, 300), [spec]), + ), + ); + + expect(firstPublished()).toEqual(px(rect(0, 50, 400, 300))); + }); + + it('a JS-driven transform is in the shadow tree already, and changes nothing', () => { + render(underAnimated([{ translateX: animatedValue(80, false) }], [{ translateX: 80 }])); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('reads the committed props of the Animated component', () => { + const value = animatedValue(0); + const fiber = underAnimated([{ translateX: 5 }], [{ translateX: 5 }]); + const wrapper = ancestorOfType(fiber, AnimatedView); + // A render in flight gives it a native transform that has not committed. + pairWith(wrapper, { style: { transform: [{ translateX: interpolation(value, [0, 1], [0, 160]) }] } }); + + render(fiber); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); + +const ReanimatedView = Object.assign(() => null, { displayName: 'AnimatedComponent(View)' }); + +/** The view under a Reanimated component with `style`. */ +function underReanimated(style: unknown): FiberLike { + return existing( + selfIn( + root([ + scrollView(rect(0, 50, 400, 300), [ + { + tag: FiberTag.FunctionComponent, + type: ReanimatedView, + memoizedProps: { style }, + children: [view([self()])], + }, + ]), + ]), + ), + ); +} + +describe('P2-7: Reanimated', () => { + it('an animated style that sets a transform covers the whole surface, clipped', () => { + const animatedStyle = { viewDescriptors: {}, initial: { value: { transform: [{ translateY: 0 }] } } }; + render(underReanimated([{ flex: 1 }, animatedStyle])); + + expect(firstPublished()).toEqual(px(rect(0, 50, 400, 300))); + }); + + it('an animated style it cannot read is taken as moving the view', () => { + render(underReanimated({ viewDescriptors: {}, initial: {} })); + + expect(firstPublished()).toEqual(px(rect(0, 50, 400, 300))); + }); + + it('an animated style without a transform, or plain styles, move nothing', () => { + render(underReanimated([{ viewDescriptors: {}, initial: { value: { opacity: 1 } } }, null, { margin: 2 }])); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); + +describe('R3-3: content uncovered by a pop', () => { + /** A stack of screen A (holding the view) under screen B (`bProps`, its children `bChildren`). */ + function stackOfTwo( + aProps: Record, + bProps: Record = { activityState: 2 }, + bChildren: FiberSpec[] = [], + ): { a: FiberLike; screenA: FiberLike; screenB: FiberLike; stack: FiberLike } { + const tree = root([ + fabricHost('RNSScreenStack', null, { children: 2 }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, aProps, [view([self()])]), + fabricHost(SCREEN_HOST_TYPE, SCREEN, bProps, bChildren), + ]), + ]); + const selves = selvesIn(tree); + const a = selves.find((fiber) => (ancestorOfType(fiber, SCREEN_HOST_TYPE).sibling ?? null) !== null) as FiberLike; + const screenA = ancestorOfType(a, SCREEN_HOST_TYPE); + existing(a); + return { a, screenA, screenB: screenA.sibling as FiberLike, stack: ancestorOfType(a, 'RNSScreenStack') }; + } + + /** Commits the pop of B: off the stack, A active, in one commit. */ + function popB(stack: FiberLike, screenA: FiberLike): void { + screenA.sibling = null; + commitProps(screenA, { activityState: 2 }); + stack.memoizedProps = { children: 1 }; + } + + it('PROBE-N3a: content mounted while its screen was hidden, then the screen above popped: the whole screen', () => { + const { a, screenA, stack } = stackOfTwo({ activityState: 0 }); + render(a); + wait(2000); + expect((lastPublished() as [number, number[]] | undefined)?.[1] ?? []).toEqual([]); + + // B has no secure view, and the default animation. + popB(stack, screenA); + wait(17); + + expect(firstPublished()).toEqual(px(SCREEN)); + wait(SECURE_TRANSITION_HOLD_MS + 100); + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); + + it('PROBE-N3b: content mounting during a pop its popped screen\'s view saw: covered for the rest of the pop', () => { + const { a, screenA, screenB, stack } = stackOfTwo({ activityState: 2 }, { + activityState: 2, + stackAnimation: 'slide_from_left', + }, [view([self()])]); + const b = existing((screenB.child as FiberLike).child as FiberLike); + selfRect = rect(10, 400, 200, 50); + const onB = render(b); + wait(212); + + // Popped: B's view departs with it. + deleting(screenB); + unmount(onB); + popB(stack, screenA); + wait(100); + selfRect = rect(10, 100, 200, 50); + render(a); + const published = (): number[][] => { + const flat = (lastPublished() as [number, number[]])[1]; + return Array.from({ length: flat.length / 4 }, (_, index) => flat.slice(4 * index, 4 * index + 4)); + }; + + // The slide's band across the screen, at the new view. + expect(published()).toContainEqual(px(rect(0, 100, 400, 50))); + // For the rest of the pop's hold, not one from the mount. + wait(SECURE_TRANSITION_HOLD_MS - 100 + 50); + expect(published()).not.toContainEqual(px(rect(0, 100, 400, 50))); + }); + + it('PROBE-N3b: content mounting just after a pop no view saw is covered, from the stack\'s render before', () => { + const { a, screenA, screenB, stack } = stackOfTwo({ activityState: 2 }); + // The stack's render before the pop, B still on it. + const before = { ...stack, alternate: stack, memoizedProps: { children: 2 } } as FiberLike; + before.child = { ...screenA, sibling: { ...screenB, sibling: null } } as FiberLike; + stack.alternate = before; + popB(stack, screenA); + wait(100); + + render(a); + + expect(firstPublished()).toEqual(px(SCREEN)); + }); + + it('a pop its view told the stack of is not found again by a view that comes after its hold', () => { + const { a, screenA, screenB, stack } = stackOfTwo({ activityState: 2 }, { activityState: 2 }, [view([self()])]); + const onB = render(existing((screenB.child as FiberLike).child as FiberLike)); + wait(212); + deleting(screenB); + unmount(onB); + popB(stack, screenA); + wait(SECURE_TRANSITION_HOLD_MS + 200); + + render(a); + + expect(publishedRects()).not.toContainEqual(px(SCREEN)); + }); + + it('a push its view told the stack of is not found again by a view that comes after its hold', () => { + const tree = root([ + fabricHost('RNSScreenStack', null, { children: 2 }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()])]), + fabricHost(SCREEN_HOST_TYPE, SCREEN, { activityState: 2 }, [view([self()])]), + ]), + ]); + const selves = selvesIn(tree); + const stack = ancestorOfType(selves[0] as FiberLike, 'RNSScreenStack'); + const screenA = stack.child as FiberLike; + const onA = selves.find((fiber) => ancestorOfType(fiber, SCREEN_HOST_TYPE) === screenA) as FiberLike; + const onC = selves.find((fiber) => fiber !== onA) as FiberLike; + existing(onA); + // The commit pushes C: the stack rendered before with A alone. + stack.alternate = { ...stack, alternate: stack, child: { ...screenA, sibling: null } } as FiberLike; + render(onC); + wait(SECURE_TRANSITION_HOLD_MS + 200); + + render(onA); + + expect(publishedRects()).not.toContainEqual(px(SCREEN)); + }); + + it('a pop that uncovers another screen than the view\'s covers nothing of it', () => { + const { a, screenA, stack } = stackOfTwo({ activityState: 2 }); + render(a); + wait(212); + // Seen by another view: a screen pushed over a screen not the view's, then popped. + const other = {}; + stackMoving(keyOf(stack), { motion: 'any', until: 10_000, moving: other, below: {} }, true); + wait(17); + + expect(lastPublished()).toEqual([0, px(selfRect)]); + expect(screenA).toBeDefined(); + }); +}); + +describe('B6, N1: a value JS moves is covered in the JS task that moves it', () => { + it('stops watching every value as it unmounts', () => { + const stops: jest.Mock[] = []; + const real = drivers.whenMoved; + jest.spyOn(drivers, 'whenMoved').mockImplementation((node, wake) => { + const stop = jest.fn(real(node, wake)); + stops.push(stop); + return stop; + }); + const testRenderer = render( + underAnimated([{ translateY: animatedValue(0) }, { translateX: animatedValue(0, false) }], [{ translateY: 0 }]), + ); + expect(stops).toHaveLength(2); + + unmount(testRenderer); + + stops.forEach((stop) => expect(stop).toHaveBeenCalledTimes(1)); + }); + + it('B6: a loop restarting (reset to its start, then animated again) is covered at once, before any frame', () => { + // Stopped at 80, which the shadow tree holds too. + const value = animatedValue(80); + selfRect = rect(10, 180, 200, 50); + render(underAnimated([{ translateY: value }], [{ translateY: 80 }])); + wait(16 * 40); + expect(lastPublished()).toEqual([0, px(rect(10, 180, 200, 50))]); + + // Animated.loop's next iteration: resetAnimation (natively, a jump back to 0), then the timing again. + act(() => { + (value as unknown as FakeAnimatedValue).resetAnimation(); + (value as unknown as FakeAnimatedValue).animate({ ...timing(0, 80), useNativeDriver: true }); + }); + + // No frame or tick has run: covered over the loop's path (0 to 80), whichever value the shadow tree holds. + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 210))); + }); + + it('N1: a value\'s first native animation is covered in the JS task that starts it', () => { + const value = animatedValue(0, false); + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + wait(212); + + act(() => (value as unknown as FakeAnimatedValue).animate({ ...timing(0, -100), useNativeDriver: true })); + + expect(firstPublished()).toEqual(px(rect(10, 0, 200, 150))); + }); + + it('a value set from JS is covered where it is set, at once', () => { + const value = animatedValue(0); + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + wait(212); + + act(() => (value as unknown as FakeAnimatedValue).setValue(40)); + + // At 40, whichever value the shadow tree holds (0 as rendered, or 40). + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 90))); + }); +}); + +describe('R4-2: a view under a transform animated from JS (useNativeDriver: false)', () => { + it('PROBE-JS-TIMING: a 50 px view stays covered at every frame, and the frame loop stops once it ends', () => { + const value = animatedValue(0, false); + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + wait(16 * 20); + expect(jest.getTimerCount()).toBe(1); + + act(() => (value as unknown as FakeAnimatedValue).animate({ ...timing(0, 300), useNativeDriver: false })); + // At once: its whole path, ahead of where it is drawn at any step. + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 350))); + for (let frame = 1; frame <= 12; frame += 1) { + // React Native's onUpdate: the value steps, and setNativeProps moves the view in the shadow tree; the + // step drawn next is one further on. + value._value += 16; + selfRect = rect(10, 100 + 16 * frame, 200, 50); + wait(16); + const next = px(rect(10, 100 + 16 * (frame + 1), 200, 50)); + expect( + publishedRects().some(([l, t, r, b]) => l! <= next[0]! && t! <= next[1]! && r! >= next[2]! && b! >= next[3]!), + ).toBe(true); + } + + // Ended: back to the tick, with no frame loop. + value._animation = null; + wait(16 * (STEADY_FRAMES + 4)); + expect(jest.getTimerCount()).toBe(1); + }); + + it('a native value beside a value driven from JS, both at rest: the native one is still bounded', () => { + const native = animatedValue(40); + const js = animatedValue(10, false); + render(underAnimated([{ translateY: native }, { translateX: js }], [{ translateY: 0 }, { translateX: 10 }])); + + // At 40, whichever value the shadow tree holds (0 as rendered, or 40). + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 90))); + }); + + it('where nothing is followed, a JS animation is still bounded over its path', () => { + const value = animatedValue(0, false); + forgetDrivers(); + try { + value._animation = timing(0, 300); + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 350))); + } finally { + followDrivers({ + eventClass: FakeAnimatedEvent, + implementation, + exported: animated, + valueClass: FakeAnimatedValue, + }); + } + }); + + it('two values driven from JS, one of them animating: bounded over the path, and followed every frame', () => { + const still = animatedValue(10, false); + const moving = animatedValue(0, false); + render(underAnimated([{ translateY: moving }, { translateX: still }], [{ translateY: 0 }, { translateX: 10 }])); + wait(212); + + act(() => (moving as unknown as FakeAnimatedValue).animate({ ...timing(0, 300), useNativeDriver: false })); + + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 350))); + expect(jest.getTimerCount()).toBe(2); + }); + + it('a value JS tracks (Animated.timing to another value) can be anywhere: the whole surface, while it tracks', () => { + const value = animatedValue(0, false); + (value as unknown as { _tracking: unknown })._tracking = {}; + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + + expect(firstPublished()).toEqual(px(WHOLE_SURFACE)); + }); + + it('a JS spring (no path known) covers the whole surface while it runs, then only the view', () => { + const value = animatedValue(0, false); + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + wait(212); + + act(() => (value as unknown as FakeAnimatedValue).animate({ _toValue: 100, useNativeDriver: false } as never)); + expect(firstPublished()).toEqual(px(WHOLE_SURFACE)); + + value._animation = null; + wait(SECURE_SHRINK_HOLD_MS + 16 * (STEADY_FRAMES + 4)); + expect(lastPublished()).toEqual([0, px(selfRect)]); + }); +}); + +describe('P3-3: a view under a JS-driven Animated value', () => { + it('where nothing is followed, it is polled every frame', () => { + const value = animatedValue(0, false); + forgetDrivers(); + try { + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + wait(16 * 20); + // The tick, and the frame loop reading the value. + expect(jest.getTimerCount()).toBe(2); + } finally { + followDrivers({ + eventClass: FakeAnimatedEvent, + implementation, + exported: animated, + valueClass: FakeAnimatedValue, + }); + } + }); + + it('keeps no frame loop running, and is woken the moment its value is made native', () => { + const value = animatedValue(0, false); + render(underAnimated([{ translateY: value }], [{ translateY: 0 }])); + wait(16 * 20); + + // The 100 ms tick alone. + expect(jest.getTimerCount()).toBe(1); + + (value as unknown as FakeAnimatedValue).__makeNative(); + value._animation = timing(0, 100); + wait(17); + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 150))); + }); +}); + +/** A stand-in for `value.interpolate(config)` with React Native's own `map`. */ +function interpolationOf(parent: object, config: Record, map: (input: number) => number): object { + return { + __isNative: true, + _parent: parent, + _config: config, + _getInterpolation: () => map, + __getValue: () => map((parent as FakeValue).__getValue()), + }; +} + +/** + * A ScrollView's content container measured at `content`, holding `inner`, as React Native renders it on the + * platform mocked: on Android (vertical) through the `View` component, an `RCTView` host below a component; on iOS + * an `RCTScrollContentView` host. + */ +function contentSpec(content: WindowRect | null, inner: FiberSpec[]): FiberSpec { + return mockPlatform.OS === 'android' + ? composite([fabricHost('RCTView', content, {}, inner)]) + : fabricHost('RCTScrollContentView', content, {}, inner); +} + +/** The content container's host of the ScrollView above `fiber`. */ +const contentAbove = (fiber: FiberLike): FiberLike => contentOf(ancestorOfType(fiber, 'RCTScrollView')) as FiberLike; + +describe('R3-1, R3-4: values driven by a ScrollView\'s offset', () => { + /** Native tags are never reused: a new one for each ScrollView. */ + let nextTag = 1000; + + /** A ScrollView (viewport at `viewport`, content `content`) whose content holds `inner`, its host tagged `tag`. */ + function scroller(viewport: WindowRect, content: WindowRect, inner: FiberSpec[], tag = nextTag): FiberSpec { + return { + ...fabricHost('RCTScrollView', viewport, {}, [contentSpec(content, inner)]), + stateNode: { rect: viewport, canonical: { currentProps: {}, nativeTag: tag } }, + }; + } + + /** React Native's sticky header: an Animated.View translated by `scrollY.interpolate(...)` past `at`. */ + function stickyHeader(scrollY: object, at: number): FiberSpec { + const sticky = interpolationOf( + scrollY, + { inputRange: [-1, 0, at, at + 1], outputRange: [0, 0, 0, 1] }, + (input) => Math.max(0, input - at), + ); + return { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: sticky }] } }, + children: [fabricHost('RCTView', rect(0, 300, 400, 50), { style: { transform: [{ translateY: 0 }] } }, [ + view([self()]), + ])], + }; + } + + function sticky(): { fiber: FiberLike; content: FiberLike; scrollY: FakeValue } { + const scrollY = animatedValue(0); + const fiber = existing( + selfIn(root([scroller(rect(0, 100, 400, 600), rect(0, 100, 400, 3000), [stickyHeader(scrollY, 200)])])), + ); + // React Native's ScrollView, with stickyHeaderIndices, attaches its scroll to its own value directly. + implementation.attachNativeEvent({ __internalInstanceHandle: ancestorOfType(fiber, 'RCTScrollView') }, 'onScroll', [ + { nativeEvent: { contentOffset: { y: scrollY } } }, + ]); + return { fiber, content: contentAbove(fiber), scrollY }; + } + + it('R3-1: a sticky header holding the view is covered while it is pinned and the list scrolls', () => { + const { fiber, content } = sticky(); + selfRect = rect(10, 300, 200, 50); + render(fiber); + // Its content scrolls by the same offset, natively ahead of the shadow tree: the whole viewport. + expect(lastPublished()).toEqual([0, px(rect(0, 100, 400, 600))]); + + // Scrolled 500 past its place: the shadow tree's content moved up; native pins the header at the top. + moveTo(content, rect(0, -400, 400, 3000)); + selfRect = rect(10, -100, 200, 50); + wait(17); + + expect(firstPublished()).toEqual(px(rect(0, 100, 400, 600))); + }); + + it('a header inside another ScrollView\'s content than the one driving it is bounded by the driver\'s reach', () => { + const scrollY = animatedValue(0); + const outerTag = nextTag; + nextTag += 1; + const driver = scroller(rect(0, 0, 400, 800), rect(0, 0, 400, 1000), [], outerTag); + const header = { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: parallax(scrollY) }] } }, + children: [fabricHost('RCTView', rect(0, 0, 400, 100), {}, [view([self()])])], + }; + const fiber = existing( + selfIn(root([view([scroller(rect(0, 0, 400, 800), rect(0, 0, 400, 9000), [header], nextTag), driver])])), + ); + nextTag += 1; + new FakeAnimatedEvent([{ nativeEvent: { contentOffset: { y: scrollY } } }]).__attach(outerTag, 'onScroll'); + selfRect = rect(10, 20, 200, 50); + + render(fiber); + + // Its reach (up 200 + the overrun, down the overrun), cut to the viewport of the ScrollView it is in. + expect(lastPublished()).toEqual([0, px(rect(10, 0, 200, 20 + 50 + SCROLL_OVERRUN))]); + }); + + it('R3-1: on iOS, where a ScrollView bounces and scrollTo goes anywhere, the whole viewport', () => { + mockPlatform.OS = 'ios'; + try { + const { fiber } = sticky(); + selfRect = rect(10, 300, 200, 50); + render(fiber); + + // iOS takes points. + expect(lastPublished()).toEqual([0, [0, 100, 400, 700]]); + } finally { + mockPlatform.OS = 'android'; + } + }); + + /** A header over an `Animated.ScrollView` (tag 77) whose `onScroll` drives `translateY` through `header`. */ + function collapsing(header: (scrollY: object) => unknown, content = rect(0, 0, 400, 1000)): FiberLike { + const scrollY = animatedValue(0); + const fiber = existing( + selfIn( + root([ + view([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: header(scrollY) }] } }, + children: [fabricHost('RCTView', rect(0, 0, 400, 100), { style: { transform: [{ translateY: 0 }] } }, [ + view([self()]), + ])], + }, + scroller(rect(0, 0, 400, 800), content, []), + ]), + ]), + ), + ); + // An Animated.ScrollView attaches its onScroll event to its scrollable node's tag. + new FakeAnimatedEvent([{ nativeEvent: { contentOffset: { y: scrollY } } }]).__attach(nextTag, 'onScroll'); + nextTag += 1; + return fiber; + } + + const parallax = (scrollY: object): unknown => + interpolationOf(scrollY, { inputRange: [0, 100], outputRange: [0, -100] }, (input) => -input); + + it('R3-4: a header moved by a scroll it does not clamp is covered over the scroll\'s reach alone', () => { + selfRect = rect(10, 20, 200, 50); + render(collapsing(parallax)); + + // Content 1000 in a viewport of 800: offsets 0 to 200, and a fling's overrun either way. + expect(lastPublished()).toEqual([ + 0, + px(rect(10, 20 - 200 - SCROLL_OVERRUN, 200, 50 + 200 + 2 * SCROLL_OVERRUN)), + ]); + }); + + it('R3-4: on iOS, a header over a ScrollView is unbounded (it bounces, and scrollTo goes anywhere)', () => { + mockPlatform.OS = 'ios'; + try { + selfRect = rect(10, 20, 200, 50); + render(collapsing(parallax)); + + expect(lastPublished()).toEqual([0, [0, 0, WHOLE_SURFACE.width, WHOLE_SURFACE.height]]); + } finally { + mockPlatform.OS = 'android'; + } + }); + + it('a transform around the ScrollView whose offset drives it, the view inside its content, is unbounded', () => { + const scrollY = animatedValue(0); + const at = nextTag; + nextTag += 1; + const fiber = existing( + selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: parallax(scrollY) }] } }, + children: [ + fabricHost('RCTView', rect(0, 0, 400, 800), {}, [ + scroller(rect(0, 0, 400, 800), rect(0, 0, 400, 1000), [view([self()])], at), + ]), + ], + }, + ]), + ), + ); + new FakeAnimatedEvent(offsetY(scrollY)).__attach(at, 'onScroll'); + selfRect = rect(10, 20, 200, 50); + + render(fiber); + + // The content moves by the same offset, natively ahead of the shadow tree: nothing above to cut it. + expect(firstPublished()).toEqual(px(WHOLE_SURFACE)); + }); + + describe('P3-A: a sticky header with a next one (flat at both ends) when its scroll is not followed', () => { + /** RN's interpolation for a sticky header at 200 that the next one, at 700, pushes off: flat at both ends. */ + function stickyNext(scrollY: object): FiberLike { + const translateY = interpolationOf( + scrollY, + { inputRange: [-1, 0, 200, 201, 700, 701], outputRange: [0, 0, 0, 1, 500, 500] }, + (input) => Math.min(500, Math.max(0, input - 200)), + ); + const header = { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY }] } }, + children: [fabricHost('RCTView', rect(0, 300, 400, 50), { style: { transform: [{ translateY: 0 }] } }, [ + view([self()]), + ])], + }; + return existing(selfIn(root([scroller(rect(0, 100, 400, 600), rect(0, 100, 400, 3000), [header])]))); + } + const VIEWPORT = px(rect(0, 100, 400, 600)); + + it('PROBE-STICKY-NEXT: nothing followed (a React Native file moved): the whole viewport', () => { + const scrollY = animatedValue(0); + forgetDrivers(); + try { + selfRect = rect(10, 300, 200, 50); + render(stickyNext(scrollY)); + + expect(firstPublished()).toEqual(VIEWPORT); + } finally { + followDrivers({ + eventClass: FakeAnimatedEvent, + implementation, + exported: animated, + valueClass: FakeAnimatedValue, + }); + } + }); + + it('PROBE-STICKY-NEXT: a scroll value made native before the package loaded: the whole viewport', () => { + selfRect = rect(10, 300, 200, 50); + render(stickyNext(animatedValue(0, true, false))); + + expect(firstPublished()).toEqual(VIEWPORT); + }); + + it('a scroll attached to a ScrollView that cannot be found: the whole viewport', () => { + const scrollY = animatedValue(0); + const fiber = stickyNext(scrollY); + new FakeAnimatedEvent([{ nativeEvent: { contentOffset: { y: scrollY } } }]).__attach(-7, 'onScroll'); + selfRect = rect(10, 300, 200, 50); + + render(fiber); + + expect(firstPublished()).toEqual(VIEWPORT); + }); + + it('followed and found: the whole viewport too (the control)', () => { + const scrollY = animatedValue(0); + const fiber = stickyNext(scrollY); + implementation.attachNativeEvent( + { __internalInstanceHandle: ancestorOfType(fiber, 'RCTScrollView') }, + 'onScroll', + [{ nativeEvent: { contentOffset: { y: scrollY } } }], + ); + selfRect = rect(10, 300, 200, 50); + + render(fiber); + + expect(firstPublished()).toEqual(VIEWPORT); + }); + + it('a swipeable row in a list (a gesture, followed) stays bounded over its outputs', () => { + const dragX = animatedValue(0); + new FakeAnimatedEvent([{ nativeEvent: { translationX: dragX } }]).__attach(5, 'onGestureEvent'); + const translateX = interpolationOf( + dragX, + { inputRange: [-101, -100, 80, 81], outputRange: [-100, -100, 80, 80] }, + (input) => Math.min(80, Math.max(-100, input)), + ); + const row = { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateX }] } }, + children: [fabricHost('RCTView', rect(0, 300, 400, 50), {}, [view([self()])])], + }; + selfRect = rect(110, 300, 200, 50); + + render(existing(selfIn(root([scroller(rect(0, 100, 400, 600), rect(0, 100, 400, 3000), [row])])))); + + expect(firstPublished()).toEqual(px(rect(10, 300, 380, 50))); + }); + + it('nothing followed, with no ScrollView above: still bounded by its flat ends', () => { + const scrollY = animatedValue(0); + const translateY = interpolationOf( + scrollY, + { inputRange: [0, 1, 2, 3], outputRange: [0, 0, 40, 40] }, + (input) => Math.min(40, Math.max(0, (input - 1) * 40)), + ); + forgetDrivers(); + try { + selfRect = rect(10, 100, 200, 50); + render(underAnimated([{ translateY }], [{ translateY: 0 }])); + + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 90))); + } finally { + followDrivers({ + eventClass: FakeAnimatedEvent, + implementation, + exported: animated, + valueClass: FakeAnimatedValue, + }); + } + }); + + it('a JS-driven animation on a header inside a ScrollView moves no content: bounded over its path', () => { + const value = animatedValue(0, false); + value._animation = timing(0, 100); + const header = { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: value }] } }, + children: [fabricHost('RCTView', rect(0, 300, 400, 50), {}, [view([self()])])], + }; + selfRect = rect(10, 300, 200, 50); + + render(existing(selfIn(root([scroller(rect(0, 100, 400, 600), rect(0, 100, 400, 3000), [header])])))); + + expect(firstPublished()).toEqual(px(rect(10, 300, 200, 150))); + }); + + it('a still value of unknown driver with no ScrollView above: bounded by its flat ends', () => { + const scrollY = animatedValue(0, true, false); + const translateY = interpolationOf( + scrollY, + { inputRange: [0, 1, 2, 3], outputRange: [0, 0, 40, 40] }, + (input) => Math.min(40, Math.max(0, (input - 1) * 40)), + ); + selfRect = rect(10, 100, 200, 50); + + render(underAnimated([{ translateY }], [{ translateY: 0 }])); + + expect(firstPublished()).toEqual(px(rect(10, 100, 200, 90))); + }); + }); + + it('a sticky header with a still value beside its scroll is unbounded all the same', () => { + const { fiber } = sticky(); + const wrapper = ancestorOfType(fiber, AnimatedView); + const { style } = wrapper.memoizedProps as { style: { transform: Array> } }; + wrapper.memoizedProps = { style: { transform: [...style.transform, { translateX: animatedValue(0) }] } }; + selfRect = rect(10, 300, 200, 50); + + render(fiber); + + expect(lastPublished()).toEqual([0, px(rect(0, 100, 400, 600))]); + }); + + it('R3-4: content shorter than its viewport scrolls only by the overrun', () => { + selfRect = rect(10, 20, 200, 50); + render(collapsing(parallax, rect(0, 0, 400, 300))); + + expect(lastPublished()).toEqual([0, px(rect(10, 20 - SCROLL_OVERRUN, 200, 50 + 2 * SCROLL_OVERRUN))]); + }); + + it.each<[string, (scrollY: object) => object]>([ + ['a ScrollView not found by its tag', (scrollY) => scrollY], + ['an event that is not the scroll', (scrollY) => scrollY], + ])('R3-4: the unbounded fallback: %s covers the whole surface', (name, header) => { + selfRect = rect(10, 20, 200, 50); + const fiber = collapsing(header); + const { style } = ancestorOfType(fiber, AnimatedView).memoizedProps as { + style: { transform: [{ translateY: object }] }; + }; + const scrollY = style.transform[0].translateY; + // Another attachment on the same value, which bounds nothing. + new FakeAnimatedEvent([{ nativeEvent: { contentOffset: { y: scrollY } } }]).__attach( + name.startsWith('a ScrollView') ? -1 : nextTag - 1, + name.startsWith('a ScrollView') ? 'onScroll' : 'onMomentumScrollEnd', + ); + render(fiber); + + expect(firstPublished()).toEqual(px(WHOLE_SURFACE)); + }); + + /** The header over a ScrollView, its offset attached as `attach` does it. */ + function headerOver( + attach: (scrollY: object, tag: number, scroller: FiberLike) => void, + content: WindowRect | null = rect(0, 0, 400, 1000), + type = 'RCTScrollView', + ): FiberLike { + const scrollY = animatedValue(0); + const tag = nextTag; + nextTag += 1; + const port = { ...scroller(rect(0, 0, 400, 800), rect(0, 0, 400, 1000), [], tag), type }; + (port.children as FiberSpec[])[0] = contentSpec(content, []); + const fiber = existing( + selfIn( + root([ + view([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: parallax(scrollY) }] } }, + children: [fabricHost('RCTView', rect(0, 0, 400, 100), {}, [view([self()])])], + }, + port, + ]), + ]), + ), + ); + attach(scrollY, tag, ancestorOfType(fiber, AnimatedView).sibling as FiberLike); + return fiber; + } + + const offsetY = (scrollY: object): unknown[] => [{ nativeEvent: { contentOffset: { y: scrollY } } }]; + const BOUNDED = px(rect(10, 20 - 200 - SCROLL_OVERRUN, 200, 50 + 200 + 2 * SCROLL_OVERRUN)); + + it.each<[string, (scrollY: object, tag: number, host: FiberLike) => void]>([ + ['its host, by its fiber', (scrollY, _, host) => { + implementation.attachNativeEvent({ __internalInstanceHandle: host }, 'onScroll', offsetY(scrollY)); + }], + ['its public instance, by its tag', (scrollY, tag) => { + implementation.attachNativeEvent({ __nativeTag: tag, __internalInstanceHandle: 7 }, 'onScroll', offsetY(scrollY)); + }], + ['its tag, twice (found once)', (scrollY, tag) => { + new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll'); + new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll'); + }], + ])('R3-4: a scroll attached to %s is bounded by its reach', (_, attach) => { + selfRect = rect(10, 20, 200, 50); + render(headerOver(attach)); + + expect(lastPublished()).toEqual([0, BOUNDED]); + }); + + it.each<[string, (scrollY: object, tag: number) => void, WindowRect | null, string]>([ + ['content that cannot be measured', (scrollY, tag) => { + new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll'); + }, null, 'RCTScrollView'], + ['a host that is not a ScrollView', (scrollY, tag) => { + new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll'); + }, rect(0, 0, 400, 1000), 'RCTView'], + ['an offset along no axis', (scrollY, tag) => { + new FakeAnimatedEvent([{ nativeEvent: { contentOffset: { z: scrollY } } }]).__attach(tag, 'onScroll'); + }, rect(0, 0, 400, 1000), 'RCTScrollView'], + ['something else of the event', (scrollY, tag) => { + new FakeAnimatedEvent([{ nativeEvent: { contentInset: { y: scrollY } } }]).__attach(tag, 'onScroll'); + }, rect(0, 0, 400, 1000), 'RCTScrollView'], + ['an offset deeper in the event', (scrollY, tag) => { + new FakeAnimatedEvent([{ nativeEvent: { contentOffset: { y: { y: scrollY } } } }]).__attach(tag, 'onScroll'); + }, rect(0, 0, 400, 1000), 'RCTScrollView'], + ['an offset not in nativeEvent', (scrollY, tag) => { + new FakeAnimatedEvent([{ event: { contentOffset: { y: scrollY } } }]).__attach(tag, 'onScroll'); + }, rect(0, 0, 400, 1000), 'RCTScrollView'], + ['a view that names no host', (scrollY) => { + new FakeAnimatedEvent(offsetY(scrollY)).__attach({}, 'onScroll'); + }, rect(0, 0, 400, 1000), 'RCTScrollView'], + ])('R3-4: the unbounded fallback: a scroll with %s covers the whole surface', (_, attach, content, type) => { + selfRect = rect(10, 20, 200, 50); + render(headerOver(attach, content, type)); + + expect(firstPublished()).toEqual(px(WHOLE_SURFACE)); + }); + + it('R3-4: a horizontal offset reaches along the content\'s width', () => { + selfRect = rect(10, 20, 200, 50); + // Content as wide as its viewport: only a fling's overrun. + render( + headerOver((scrollY, tag) => + new FakeAnimatedEvent([{ nativeEvent: { contentOffset: { x: scrollY } } }]).__attach(tag, 'onScroll'), + ), + ); + + expect(lastPublished()).toEqual([0, px(rect(10, 20 - SCROLL_OVERRUN, 200, 50 + 2 * SCROLL_OVERRUN))]); + }); + + it('R3-4: a ScrollView with no content container host below it bounds nothing', () => { + selfRect = rect(10, 20, 200, 50); + const fiber = headerOver((scrollY, tag) => new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll')); + const host = ancestorOfType(fiber, AnimatedView).sibling as FiberLike; + (contentOf(host) as FiberLike).tag = FiberTag.FunctionComponent; + render(fiber); + + expect(firstPublished()).toEqual(px(WHOLE_SURFACE)); + }); + + it('R3-4: one value scrolled by 40 ScrollViews: each found by its tag, however many are remembered', () => { + const scrollY = animatedValue(0); + const tags = Array.from({ length: 40 }, (_, index) => nextTag + index); + nextTag += 40; + const fiber = existing( + selfIn( + root([ + view([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: parallax(scrollY) }] } }, + children: [fabricHost('RCTView', rect(0, 0, 400, 100), {}, [view([self()])])], + }, + ...tags.map((tag) => scroller(rect(0, 0, 400, 800), rect(0, 0, 400, 1000), [], tag)), + ]), + ]), + ), + ); + tags.forEach((tag) => new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll')); + selfRect = rect(10, 20, 200, 50); + + render(fiber); + + expect(lastPublished()).toEqual([0, BOUNDED]); + }); + + it('R3-4: a swipeable row whose interpolation is flat past both ends is covered over its outputs', () => { + // RNGH's Swipeable without overshoot: the drag (a gesture event, unbounded) held within -100 to 80. + const dragX = animatedValue(0); + new FakeAnimatedEvent([{ nativeEvent: { translationX: dragX } }]).__attach(5, 'onGestureEvent'); + const translateX = interpolationOf( + dragX, + { inputRange: [-101, -100, 80, 81], outputRange: [-100, -100, 80, 80] }, + (input) => Math.min(80, Math.max(-100, input)), + ); + + render(underAnimated([{ translateX }], [{ translateX: 0 }])); + + expect(lastPublished()).toEqual([0, px(rect(10 - 100, 100, 200 + 180, 50))]); + }); + + it('R3-4: a diffClamp header over an unbounded scroll is covered over its clamp', () => { + const scrollY = animatedValue(0); + new FakeAnimatedEvent([{ nativeEvent: { contentOffset: { y: scrollY } } }]).__attach(5, 'onScroll'); + const clamp = { __isNative: true, _a: scrollY, _min: 0, _max: 60, __getValue: () => 0 }; + const header = interpolationOf(clamp, { inputRange: [0, 60], outputRange: [0, -60] }, (input) => -input); + + render(underAnimated([{ translateY: header }], [{ translateY: 0 }])); + + expect(lastPublished()).toEqual([0, px(rect(10, 40, 200, 110))]); + }); +}); + +describe('R3-4: a ScrollView\'s reach, value by value', () => { + let tag = 5000; + const offsetY = (scrollY: object): unknown[] => [{ nativeEvent: { contentOffset: { y: scrollY } } }]; + + /** + * The view under a header whose transform reads `scrollY`, beside ScrollViews built from `specs`, at the + * root: the last of them is the first host a search comes to. + */ + function tree(scrollY: object, specs: FiberSpec[]): FiberLike { + return existing( + selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: scrollY }] } }, + children: [fabricHost('RCTView', rect(0, 0, 400, 100), {}, [view([self()])])], + }, + ...specs, + ]), + ), + ); + } + + /** A ScrollView 800 tall, its content `content` (1000 tall unless given), its host's state `state`. */ + function port(state: Record | null, content: WindowRect | null = rect(0, 0, 400, 1000)): FiberSpec { + return { + ...fabricHost('RCTScrollView', rect(0, 0, 400, 800), {}, [contentSpec(content, [])]), + stateNode: state, + }; + } + const fabric = (nativeTag: number, measured: WindowRect | null = rect(0, 0, 400, 800)): Record => ({ + rect: measured, + canonical: { currentProps: {}, nativeTag }, + }); + + /** `scrollY`'s bound as a view in `fiber`'s tree measures it (which looks for hosts in that tree). */ + function boundIn(fiber: FiberLike, scrollY: object): ReturnType { + nativeMotion(ancestryOfFiber(fiber)); + return nodeBound(scrollY); + } + + const REACH = { lo: -SCROLL_OVERRUN, hi: 200 + SCROLL_OVERRUN }; + + it('a still value a scroll drives: its reach, offset included, and not running', () => { + const scrollY = animatedValue(0); + scrollY._offset = 10; + tag += 1; + const fiber = tree(scrollY, [port(fabric(tag))]); + new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll'); + + expect(boundIn(fiber, scrollY)).toEqual({ + range: { lo: REACH.lo + 10, hi: REACH.hi + 10 }, + running: false, + roots: [scrollY], + }); + }); + + it('a scroll value an animation also drives is unbounded, and running', () => { + const scrollY = animatedValue(0); + scrollY._animation = timing(0, 100); + tag += 1; + const fiber = tree(scrollY, [port(fabric(tag))]); + new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll'); + + expect(boundIn(fiber, scrollY)).toEqual({ range: undefined, running: true, roots: [scrollY] }); + }); + + it('nothing followed: unbounded, without looking for a ScrollView', () => { + const scrollY = animatedValue(0); + tag += 1; + const fiber = tree(scrollY, [port(fabric(tag))]); + forgetDrivers(); + try { + expect(boundIn(fiber, scrollY)).toEqual({ range: undefined, running: false, roots: [scrollY] }); + } finally { + followDrivers({ + eventClass: FakeAnimatedEvent, + implementation, + exported: animated, + valueClass: FakeAnimatedValue, + }); + } + }); + + it.each<[string, (scrollY: object, at: number) => void, (at: number) => FiberSpec[]]>([ + ['a scroll and a gesture', (scrollY, at) => { + new FakeAnimatedEvent(offsetY(scrollY)).__attach(at, 'onScroll'); + new FakeAnimatedEvent([{ nativeEvent: { translationY: scrollY } }]).__attach(at, 'onGestureEvent'); + }, (at) => [port(fabric(at))]], + ['an offset along no axis', (scrollY, at) => { + new FakeAnimatedEvent([{ nativeEvent: { contentOffset: { z: scrollY } } }]).__attach(at, 'onScroll'); + }, (at) => [port(fabric(at))]], + ['content that cannot be measured', (scrollY, at) => { + new FakeAnimatedEvent(offsetY(scrollY)).__attach(at, 'onScroll'); + }, (at) => [port(fabric(at), null)]], + ['a viewport that cannot be measured', (scrollY, at) => { + new FakeAnimatedEvent(offsetY(scrollY)).__attach(at, 'onScroll'); + }, (at) => [port(fabric(at, null))]], + ['no view', (scrollY) => { + implementation.attachNativeEvent(null, 'onScroll', offsetY(scrollY)); + }, (at) => [port(fabric(at))]], + ['a view with no tag, beside a ScrollView with none', (scrollY) => { + implementation.attachNativeEvent({}, 'onScroll', offsetY(scrollY)); + }, () => [port({ rect: rect(0, 0, 400, 800), canonical: { currentProps: {} } })]], + ])('%s: unbounded', (_, attach, specs) => { + const scrollY = animatedValue(0); + tag += 1; + const fiber = tree(scrollY, specs(tag)); + attach(scrollY, tag); + + expect(boundIn(fiber, scrollY).range).toBeUndefined(); + }); + + it.each<[string, (at: number) => unknown, (at: number) => FiberSpec[]]>([ + ['a public instance whose handle is no fiber', (at) => ({ __internalInstanceHandle: {}, __nativeTag: at }), + (at) => [port(fabric(at))], + ], + ['a public instance with no handle', (at) => ({ __internalInstanceHandle: null, __nativeTag: at }), (at) => [ + port(fabric(at)), + ]], + ['a Paper host, by its tag', (at) => at, (at) => [port({ rect: rect(0, 0, 400, 800), _nativeTag: at })]], + ['a tag found past a host with no state', (at) => at, (at) => [ + port(fabric(at)), + { tag: FiberTag.HostComponent, type: 'RCTView', stateNode: null, memoizedProps: {} }, + ]], + // Looked at first: a component is no host, whatever its state holds. + ['a tag also on a component', (at) => at, (at) => [ + port(fabric(at)), + { ...composite([]), stateNode: { canonical: { nativeTag: at } } }, + ]], + ])('found from %s: bounded by its reach', (_, viewOf, specs) => { + const scrollY = animatedValue(0); + tag += 1; + const fiber = tree(scrollY, specs(tag)); + implementation.attachNativeEvent(viewOf(tag), 'onScroll', offsetY(scrollY)); + + expect(boundIn(fiber, scrollY).range).toEqual(REACH); + }); + + it.each<[string, Array>, Array>, boolean]>([ + ['under a host scaled down', [{ scale: 0.5 }], [], false], + ['itself scaled up on one axis', [], [{ scaleX: 2 }], false], + ['under a host scaled by what cannot be read', [{ scale: 'big' }], [], false], + ['inverted (scaleY -1, an inverted list)', [], [{ scaleY: -1 }], true], + [ + 'under a host scaled by an Animated value at 1, beside a translation', + [{ translateY: 5 }, { scale: { __getValue: () => 1 } }], + [], + true, + ], + ])('P3-5: a ScrollView %s: bounded only at a scale of 1 or -1', (_, above, own, bounded) => { + const scrollY = animatedValue(0); + tag += 1; + const scroll = port(fabric(tag)); + scroll.memoizedProps = { style: { transform: own } }; + const scaler = fabricHost('RCTView', rect(0, 0, 400, 800), { style: { transform: above } }, [scroll]); + const fiber = tree(scrollY, [scaler]); + new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll'); + + expect(boundIn(fiber, scrollY).range).toEqual(bounded ? REACH : undefined); + }); + + it('P3-5: a component above a ScrollView styled with a scale is no host: committed scales are read off hosts', () => { + const scrollY = animatedValue(0); + tag += 1; + const scaler = { ...composite([port(fabric(tag))]), memoizedProps: { style: { transform: [{ scale: 0.5 }] } } }; + const fiber = tree(scrollY, [scaler]); + new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll'); + + expect(boundIn(fiber, scrollY).range).toEqual(REACH); + }); + + it('P3-5: a ScrollView whose parents run in a circle is read up to a limit, and taken as unscaled', () => { + const scrollY = animatedValue(0); + tag += 1; + const fiber = tree(scrollY, [port(fabric(tag))]); + const scroller = ancestorOfType(fiber, AnimatedView).sibling as FiberLike; + const loop = { ...(scroller.return as FiberLike), tag: FiberTag.FunctionComponent } as FiberLike; + loop.return = loop; + new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag, 'onScroll'); + nativeMotion(ancestryOfFiber(fiber)); + scroller.return = loop; + + expect(nodeBound(scrollY).range).toEqual(REACH); + }); + + it('a ScrollView found once is remembered, by attachment and by tag; a tag that moved is looked for again', () => { + const scrollY = animatedValue(0); + tag += 1; + const fiber = tree(scrollY, [port(fabric(tag))]); + const event = new FakeAnimatedEvent(offsetY(scrollY)); + event.__attach(tag, 'onScroll'); + expect(boundIn(fiber, scrollY).range).toEqual(REACH); + const scroller = ancestorOfType(fiber, AnimatedView).sibling as FiberLike; + + // Out of the tree (its events are detached as it unmounts; here, not yet): still found, by both. + scroller.sibling = null; + (ancestorOfType(fiber, AnimatedView).sibling as FiberLike | null) = null; + expect(boundIn(fiber, scrollY).range).toEqual(REACH); + event.__detach(tag, 'onScroll'); + event.__attach(tag, 'onScroll'); + expect(boundIn(fiber, scrollY).range).toEqual(REACH); + + // A host remembered by a tag it no longer has: looked for again, and not found. + event.__detach(tag, 'onScroll'); + (scroller.stateNode as { canonical: { nativeTag: number } }).canonical.nativeTag = -2; + event.__attach(tag, 'onScroll'); + expect(boundIn(fiber, scrollY).range).toBeUndefined(); + }); + + it('a tree that runs in a circle is searched, and given up on', () => { + const scrollY = animatedValue(0); + tag += 1; + const fiber = tree(scrollY, [port(fabric(tag))]); + const top = ancestorOfType(fiber, AnimatedView).return as FiberLike; + (top.child as FiberLike).sibling = top; + new FakeAnimatedEvent(offsetY(scrollY)).__attach(tag + 1000, 'onScroll'); + + expect(boundIn(fiber, scrollY).range).toBeUndefined(); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/BugseeSecure.visible.test.tsx b/packages/react-native/src/secure/__tests__/BugseeSecure.visible.test.tsx index 057ad334..82ab24d0 100644 --- a/packages/react-native/src/secure/__tests__/BugseeSecure.visible.test.tsx +++ b/packages/react-native/src/secure/__tests__/BugseeSecure.visible.test.tsx @@ -12,6 +12,7 @@ import type { ReactElement } from 'react'; import { create } from 'react-test-renderer'; import type { ReactTestRenderer } from 'react-test-renderer'; import { BugseeSecure } from '../BugseeSecure'; +import { SECURE_TRANSITION_HOLD_MS } from '../transition'; import { SCREEN_HOST_TYPE } from '../ancestry'; import { SECURE_SHRINK_HOLD_MS } from '../hold'; import { MODAL_HOST_TYPE } from '../surface'; @@ -72,6 +73,10 @@ beforeEach(() => { afterEach(() => { act(() => mounted.forEach((mountedRenderer) => mountedRenderer.unmount())); mounted = []; + // What an unmount leaves held runs out on the tick. + act(() => { + jest.advanceTimersByTime(SECURE_TRANSITION_HOLD_MS + 200); + }); expect(jest.getTimerCount()).toBe(0); jest.useRealTimers(); jest.restoreAllMocks(); @@ -329,6 +334,9 @@ describe(' ancestors cost and failures', () => { // The instance is looked up once; a fresh mount looks it up again. act(() => mounted.forEach((mountedRenderer) => mountedRenderer.unmount())); mounted = []; + act(() => { + jest.advanceTimersByTime(SECURE_TRANSITION_HOLD_MS + 200); + }); render(selfIn(root([scrollView(rect(0, 0, 400, 130), [self()])]))); expect(lastPublished()).toEqual([0, px(selfRect)]); diff --git a/packages/react-native/src/secure/__tests__/ancestry.test.ts b/packages/react-native/src/secure/__tests__/ancestry.test.ts index d0ebf0d3..1d9390f3 100644 --- a/packages/react-native/src/secure/__tests__/ancestry.test.ts +++ b/packages/react-native/src/secure/__tests__/ancestry.test.ts @@ -8,6 +8,7 @@ import { FiberTag } from '../../viewtree/fiber'; import type { FiberLike } from '../../viewtree/fiber'; import { + CONTENT_DEPTH, MODAL_SCREEN_HOST_TYPE, ROOT_KIND_HOST_TYPES, SCREEN_CONTAINER_HOST_TYPES, @@ -17,6 +18,7 @@ import { ancestryOfInstance, clipToAncestors, clipWatchNodes, + contentOf, isHidden, measureFiber, measurePass, @@ -25,6 +27,7 @@ import { styleValue, } from '../ancestry'; import type { Ancestry } from '../ancestry'; +import type { FiberSpec } from '../../viewtree/__tests__/fakeFibers'; import { ancestorOfType, commitProps, @@ -36,6 +39,7 @@ import { pairWith, rect, root, + iosScrollView, scrollView, screenContainer, self, @@ -85,7 +89,7 @@ const inactive = (children: Parameters[3]) => 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 fiber = selfIn(root([view([composite([iosScrollView(rect(0, 0, 1, 1), [self()])])])])); const ancestry = ancestryOfFiber(fiber); expect(ancestry.path.map((entry) => entry.tag)).toEqual([ @@ -118,7 +122,7 @@ describe('ancestryOfFiber', () => { '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])])); + const ancestry = ancestryIn(root([iosScrollView(null, [boundary])])); expect(types(ancestry)).toEqual(['RCTView']); // The view below it ends at the boundary, not at the next host above. @@ -129,7 +133,7 @@ describe('ancestryOfFiber', () => { 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])])])); + const ancestry = ancestryIn(root([iosScrollView(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 }); @@ -157,7 +161,7 @@ describe('ancestryOfFiber', () => { const ancestry = ancestryIn( root([ fabricHost(SCREEN_HOST_TYPE, null, {}, [ - scrollView(null, [scrollView(null, [self()], 'AndroidHorizontalScrollView')]), + iosScrollView(null, [iosScrollView(null, [self()], 'AndroidHorizontalScrollView')]), ]), ]), ); @@ -219,7 +223,16 @@ describe('ancestryOfInstance', () => { it('noAncestry hides nothing and clips nothing', () => { const none = noAncestry(); - expect(none).toEqual({ path: [], root: null, hosts: [], screens: [], readable: false }); + expect(none).toEqual({ + self: null, + path: [], + root: null, + hosts: [], + screens: [], + animated: [], + readable: false, + resolved: undefined, + }); expect(isHidden(none)).toBe(false); expect(clipToAncestors(rect(1, 2, 3, 4), none)).toEqual(rect(1, 2, 3, 4)); expect(noAncestry()).not.toBe(none); @@ -266,7 +279,7 @@ describe('isHidden', () => { 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])])])); + return ancestryIn(root([inactive([iosScrollView(null, [stack])])])); } it.each([ @@ -482,7 +495,7 @@ describe('isHidden', () => { }); it('with props that throw while read, even after a nearer host read as clipping', () => { - const fiber = selfIn(root([inactive([scrollView(null, [self()])])])); + const fiber = selfIn(root([inactive([iosScrollView(null, [self()])])])); commitProps(ancestorOfType(fiber, SCREEN_HOST_TYPE), { get style(): never { throw new Error('torn props'); @@ -541,7 +554,7 @@ describe('isHidden', () => { }); it('records which hosts clip', () => { - const open = view([scrollView(null, [self()])], { style: { overflow: 'visible' } }); + const open = view([iosScrollView(null, [self()])], { style: { overflow: 'visible' } }); const ancestry = ancestryIn(root([view([open], { style: { overflow: 'hidden' } })])); isHidden(ancestry); @@ -562,7 +575,7 @@ describe('isHidden', () => { [{}, true], [undefined, true], ])('a ScrollView styled %p clips: %p', (style, clips) => { - const fiber = selfIn(root([scrollView(null, [self()])])); + const fiber = selfIn(root([iosScrollView(null, [self()])])); commitProps(ancestorOfType(fiber, 'RCTScrollView'), { style }); const ancestry = ancestryOfFiber(fiber); @@ -603,7 +616,7 @@ describe('clipToAncestors', () => { }); 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()])])); + const ancestry = readAncestry(root([iosScrollView(rect(0, 0, 400, 110), [self()])])); ancestry.readable = false; expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); @@ -611,10 +624,11 @@ describe('clipToAncestors', () => { }); 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()])])); + const only = readAncestry(root([iosScrollView(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()])])])); + const inner = iosScrollView(rect(0, 0, 0, 0), [self()]); + const nested = readAncestry(root([iosScrollView(rect(0, 0, 400, 120), [inner])])); expect(clipToAncestors(SELF_RECT, nested)).toEqual(rect(10, 100, 200, 20)); }); @@ -624,11 +638,11 @@ describe('clipToAncestors', () => { ['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(); + expect(clipToAncestors(SELF_RECT, readAncestry(root([iosScrollView(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()])])); + const ancestry = readAncestry(root([iosScrollView(rect(0, 0, 0, 0), [self()])])); measurePass(() => { clipToAncestors(SELF_RECT, ancestry); @@ -639,7 +653,7 @@ describe('clipToAncestors', () => { }); it('is the whole rectangle inside the viewport', () => { - expect(clipToAncestors(SELF_RECT, readAncestry(root([scrollView(rect(0, 0, 400, 800), [self()])])))).toEqual( + expect(clipToAncestors(SELF_RECT, readAncestry(root([iosScrollView(rect(0, 0, 400, 800), [self()])])))).toEqual( SELF_RECT, ); }); @@ -651,7 +665,7 @@ describe('clipToAncestors', () => { ['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); + expect(clipToAncestors(SELF_RECT, readAncestry(root([iosScrollView(viewport, [self()])])))).toEqual(expected); }); it.each([ @@ -661,12 +675,12 @@ describe('clipToAncestors', () => { ['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(); + expect(clipToAncestors(SELF_RECT, readAncestry(root([iosScrollView(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])])); + const horizontal = iosScrollView(rect(50, 0, 400, 800), [self()], 'AndroidHorizontalScrollView'); + const ancestry = readAncestry(root([iosScrollView(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']); @@ -681,7 +695,7 @@ describe('clipToAncestors', () => { }); it('follows the ancestors as they move', () => { - const fiber = selfIn(root([scrollView(rect(0, 0, 400, 800), [self()])])); + const fiber = selfIn(root([iosScrollView(rect(0, 0, 400, 800), [self()])])); const ancestry = ancestryOfFiber(fiber); isHidden(ancestry); expect(clipToAncestors(SELF_RECT, ancestry)).toEqual(SELF_RECT); @@ -692,7 +706,7 @@ describe('clipToAncestors', () => { }); it('looks each clipping host up once, however often it measures', () => { - const ancestry = readAncestry(root([scrollView(rect(0, 0, 400, 800), [self()])])); + const ancestry = readAncestry(root([iosScrollView(rect(0, 0, 400, 800), [self()])])); clipToAncestors(SELF_RECT, ancestry); clipToAncestors(SELF_RECT, ancestry); @@ -715,8 +729,8 @@ describe('clipToAncestors', () => { 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) => { + function twoUnder(viewport: Parameters[0]): Ancestry[] { + return selvesIn(root([iosScrollView(viewport, [self(), self()])])).map((fiber) => { const ancestry = ancestryOfFiber(fiber); isHidden(ancestry); return ancestry; @@ -779,7 +793,7 @@ describe('clipToAncestors', () => { 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()])])])); + const ancestry = readAncestry(root([iosScrollView(rect(0, 0, 400, 120), [iosScrollView(null, [self()])])])); expect(clipToAncestors(SELF_RECT, ancestry)).toBe(SELF_RECT); }); @@ -790,19 +804,20 @@ describe('clipToAncestors', () => { ['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()])])])); + const ancestry = readAncestry(root([iosScrollView(rect(0, 0, 400, 110), [iosScrollView(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()])])])); + const ancestry = readAncestry(root([iosScrollView(null, [iosScrollView(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 inner = iosScrollView(rect(0, 0, 400, 120), [self()]); + const fiber = selfIn(root([iosScrollView(rect(0, 0, 400, 800), [inner])])); const ancestry = ancestryOfFiber(fiber); const outer = ancestry.path[ancestry.hosts[3]!.index]!; isHidden(ancestry); @@ -818,7 +833,7 @@ describe('clipToAncestors', () => { }); it('when measuring a clipping ancestor throws', () => { - const ancestry = readAncestry(root([scrollView(rect(0, 0, 400, 120), [self()])])); + const ancestry = readAncestry(root([iosScrollView(rect(0, 0, 400, 120), [self()])])); renderer.getPublicInstanceFromInternalInstanceHandle = () => ({ measureInWindow: () => { throw new Error('detached'); @@ -829,7 +844,7 @@ describe('clipToAncestors', () => { }); it('when the renderer cannot be loaded', () => { - const ancestry = readAncestry(root([scrollView(rect(0, 0, 400, 120), [self()])])); + const ancestry = readAncestry(root([iosScrollView(rect(0, 0, 400, 120), [self()])])); jest.doMock(RENDERER_PROXY, () => { throw new Error('no renderer'); }); @@ -845,7 +860,7 @@ describe('clipToAncestors', () => { ['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()])])); + const ancestry = readAncestry(root([iosScrollView(rect(0, 1000, 400, 120), [self()])])); expect(clipToAncestors(measured, ancestry)).toBe(measured); expect(renderer.measures).toEqual([]); @@ -857,7 +872,7 @@ describe('clipToAncestors', () => { 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])])])); + const ancestry = ancestryIn(root([inactive([iosScrollView(rect(0, 0, 1, 1), [portal])])])); expect(types(ancestry)).toEqual(['RCTView']); expect(isHidden(ancestry)).toBe(false); @@ -980,7 +995,7 @@ describe('round 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 clipping = fabricHost('RCTView', null, { style: { overflow: 'hidden' } }, [iosScrollView(null, [self()])]); const fiber = selfIn(root([clipping])); const ancestry = ancestryOfFiber(fiber); isHidden(ancestry); @@ -993,6 +1008,33 @@ describe('round 3', () => { expect(clipWatchNodes(ancestry)[0]).toBe(ancestorOfType(fiber, 'RCTScrollContentView')); }); + it('R4-1: on Android, the content container\'s host under the View component a vertical ScrollView renders', () => { + const fiber = selfIn(root([scrollView(rect(0, 0, 400, 300), [view([self()])])])); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + const scroller = ancestorOfType(fiber, 'RCTScrollView'); + + expect(clipWatchNodes(ancestry)).toEqual([(scroller.child as FiberLike).child, scroller]); + expect(contentOf(scroller)).toBe((scroller.child as FiberLike).child); + }); + + it(`looks for the content container through at most ${CONTENT_DEPTH} components`, () => { + const nested = (depth: number, inner: FiberSpec[]): FiberSpec[] => + depth === 0 ? inner : [composite(nested(depth - 1, inner))]; + for (const depth of [CONTENT_DEPTH - 1, CONTENT_DEPTH]) { + const content = fabricHost('RCTView', rect(0, 0, 400, 900), {}, [self()]); + const fiber = selfIn(root([fabricHost('RCTScrollView', rect(0, 0, 400, 300), {}, nested(depth, [content]))])); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + const scroller = ancestorOfType(fiber, 'RCTScrollView'); + const found = depth < CONTENT_DEPTH ? ancestorOfType(fiber, 'RCTView') : undefined; + + expect(clipWatchNodes(ancestry)).toEqual(found === undefined ? [scroller] : [found, scroller]); + expect(contentOf(scroller)).toBe(found); + } + expect(contentOf(fabricHost('RCTScrollView', null, {}, []) as never)).toBeUndefined(); + }); + 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])); @@ -1006,14 +1048,14 @@ describe('round 3', () => { isHidden(ancestry); expect(clipWatchNodes(ancestry)).toEqual([]); - const unread = ancestryIn(root([scrollView(null, [self()])])); + const unread = ancestryIn(root([iosScrollView(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'); + const fiber = ancestorOfType(selfIn(root([iosScrollView(rect(0, 0, 400, 120), [self()])])), 'RCTScrollView'); expect(measureFiber(fiber)).toEqual(rect(0, 0, 400, 120)); measurePass(() => { @@ -1026,8 +1068,8 @@ describe('round 3', () => { }); 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'); + const zero = ancestorOfType(selfIn(root([iosScrollView(rect(0, 0, 0, 0), [self()])])), 'RCTScrollView'); + const silent = ancestorOfType(selfIn(root([iosScrollView(null, [self()])])), 'RCTScrollView'); expect(measureFiber(zero)).toBeNull(); expect(measureFiber(silent)).toBeUndefined(); @@ -1126,7 +1168,7 @@ describe('round 3: edges', () => { }); it('watches nothing once a read fails, whatever clipped before', () => { - const ancestry = ancestryIn(root([scrollView(null, [self()])])); + const ancestry = ancestryIn(root([iosScrollView(null, [self()])])); isHidden(ancestry); expect(clipWatchNodes(ancestry)).toHaveLength(2); diff --git a/packages/react-native/src/secure/__tests__/drivers.test.ts b/packages/react-native/src/secure/__tests__/drivers.test.ts new file mode 100644 index 00000000..f89dd36b --- /dev/null +++ b/packages/react-native/src/secure/__tests__/drivers.test.ts @@ -0,0 +1,462 @@ +/** + * `drivers.ts` follows what drives native `Animated` values: the events + * attached to them (through `AnimatedEvent` or `attachNativeEvent` directly) + * and their being made native. Anything it cannot follow is trusted for + * nothing. + */ +import { eventsOn, followDrivers, forgetDrivers, isNode, knownDriven, noteAnimating, whenMoved } from '../drivers'; +import type { DriverSources } from '../drivers'; + +/** Fresh stand-ins for React Native's four sources, unpatched. */ +function sources(): DriverSources & { + eventClass: new (mapping: unknown) => { + __attach: (...args: unknown[]) => unknown; + __detach: (...args: unknown[]) => unknown; + }; + implementation: { attachNativeEvent: jest.Mock }; + exported: { attachNativeEvent: jest.Mock }; + valueClass: new () => { + _value: number; + _animation: unknown; + __isNative: boolean; + __makeNative: () => void; + __getValue: () => number; + animate: (animation: unknown) => string; + setValue: (to: number) => void; + resetAnimation: () => void; + setOffset: () => void; + flattenOffset: () => void; + extractOffset: () => void; + }; + calls: unknown[][]; +} { + const calls: unknown[][] = []; + class AnimatedEvent { + constructor(readonly _argMapping: unknown) {} + __attach(...args: unknown[]): unknown { + calls.push(['attach', this, ...args]); + return 'attached'; + } + __detach(...args: unknown[]): unknown { + calls.push(['detach', this, ...args]); + return 'detached'; + } + } + class AnimatedValue { + _value = 0; + _animation: unknown = null; + __isNative = false; + __getValue(): number { + return this._value; + } + animate(animation: unknown): string { + calls.push(['animate', this, animation]); + this._animation = animation; + return 'animating'; + } + setValue(to: number): void { + calls.push(['setValue', this, to]); + this._value = to; + } + resetAnimation(): void { + calls.push(['resetAnimation', this]); + this._value = 0; + } + setOffset(): void { + calls.push(['setOffset', this]); + } + flattenOffset(): void { + calls.push(['flattenOffset', this]); + } + extractOffset(): void { + calls.push(['extractOffset', this]); + } + __makeNative(): void { + calls.push(['makeNative', this]); + this.__isNative = true; + } + } + const handle = (): { detach: jest.Mock } => ({ detach: jest.fn(() => 'detached') }); + const attachNativeEvent = jest.fn(handle); + return { + eventClass: AnimatedEvent, + implementation: { attachNativeEvent }, + exported: { attachNativeEvent }, + valueClass: AnimatedValue, + calls, + }; +} + +/** An `Animated.Value` stand-in. */ +const value = (): object => ({ _value: 0, __getValue: () => 0 }); + +afterEach(() => { + forgetDrivers(); +}); + +describe('followDrivers', () => { + it('follows events attached through an Animated component\'s props, until detached, per attachment', () => { + const rn = sources(); + expect(followDrivers(rn)).toBe(true); + const [x, y] = [value(), value()]; + const event = new rn.eventClass([{ nativeEvent: { contentOffset: { x, y } } }]); + + expect(event.__attach('scroller', 'onScroll')).toBe('attached'); + expect(eventsOn(x)).toEqual([ + { value: x, view: 'scroller', event: 'onScroll', path: ['nativeEvent', 'contentOffset', 'x'] }, + ]); + event.__attach('scroller', 'onScroll'); + expect(eventsOn(y)).toHaveLength(2); + expect(event.__detach('scroller', 'onScroll')).toBe('detached'); + expect(eventsOn(y)).toHaveLength(1); + event.__detach('scroller', 'onScroll'); + expect(eventsOn(y)).toEqual([]); + // React Native's own methods ran, on the event, with what they were given. + expect(rn.calls.map(([name, self, ...args]) => [name, self === event, ...args])).toEqual([ + ['attach', true, 'scroller', 'onScroll'], + ['attach', true, 'scroller', 'onScroll'], + ['detach', true, 'scroller', 'onScroll'], + ['detach', true, 'scroller', 'onScroll'], + ]); + }); + + it('finds an Animated.ValueXY\'s two values, however nested, not too deep; never counts below none', () => { + const rn = sources(); + followDrivers(rn); + const [x, y, deep, tooDeep, other] = [value(), value(), value(), value(), value()]; + const nest = (inner: unknown, levels: number): unknown => (levels === 0 ? inner : { a: nest(inner, levels - 1) }); + const xy = { x, y, __getValue: () => ({ x: 0, y: 0 }) }; + const nativeEvent = { offset: xy, deep: nest(deep, 6), tooDeep: nest(tooDeep, 7), n: 1, none: null }; + new rn.eventClass([{ nativeEvent }]).__attach(); + + expect([x, y, deep, tooDeep].map((node) => eventsOn(node)?.length)).toEqual([1, 1, 1, 0]); + expect(eventsOn(x)?.[0]?.path).toEqual(['nativeEvent', 'offset', 'x']); + expect(eventsOn(y)?.[0]?.path).toEqual(['nativeEvent', 'offset', 'y']); + // Detached more often than attached, or never attached, or with no mapping: nothing goes below none. + const twice = new rn.eventClass([{ nativeEvent: { y: other } }]); + twice.__detach(); + new rn.eventClass(undefined).__attach(); + expect(eventsOn(other)).toEqual([]); + }); + + it('R3-1: follows attachNativeEvent on AnimatedImplementation (RN\'s ScrollView), until detached once', () => { + const rn = sources(); + followDrivers(rn); + const scrollY = value(); + const mapping = [{ nativeEvent: { contentOffset: { y: scrollY } } }]; + + const handle = rn.implementation.attachNativeEvent('host', 'onScroll', mapping) as { detach: () => unknown }; + + expect(eventsOn(scrollY)).toEqual([ + { value: scrollY, view: 'host', event: 'onScroll', path: ['nativeEvent', 'contentOffset', 'y'] }, + ]); + // A second attachment, then the first detached twice: the second still drives it. + const second = rn.implementation.attachNativeEvent('host', 'onScroll', mapping) as { detach: () => unknown }; + expect(handle.detach()).toBe('detached'); + handle.detach(); + expect(eventsOn(scrollY)).toHaveLength(1); + second.detach(); + expect(eventsOn(scrollY)).toEqual([]); + }); + + it('follows the public Animated object too; one that holds the wrapper already is not wrapped again', () => { + const rn = sources(); + const original = rn.exported.attachNativeEvent; + followDrivers(rn); + const scrollY = value(); + + rn.exported.attachNativeEvent('host', 'onScroll', [{ nativeEvent: { y: scrollY } }]); + expect(eventsOn(scrollY)).toHaveLength(1); + expect(original).toHaveBeenCalledTimes(1); + + // `Animated` spread from AnimatedImplementation after it was wrapped: the same wrapper, counted once. + const spread = { ...rn.implementation }; + followDrivers({ ...rn, exported: spread }); + spread.attachNativeEvent('host', 'onScroll', [{ nativeEvent: { y: scrollY } }]); + expect(eventsOn(scrollY)).toHaveLength(2); + }); + + it('P3-2: wraps once; a second copy (Fast Refresh) shares what the first counted', () => { + const rn = sources(); + followDrivers(rn); + const attach = (rn.eventClass.prototype as { __attach: unknown }).__attach; + const scrollY = value(); + new rn.eventClass([{ nativeEvent: { y: scrollY } }]).__attach(); + + // The module evaluated again: its state starts empty, and it follows the same objects. + forgetDrivers(); + expect(followDrivers(rn)).toBe(true); + + expect((rn.eventClass.prototype as { __attach: unknown }).__attach).toBe(attach); + expect(eventsOn(scrollY)).toHaveLength(1); + // Made native once, woken once: not wrapped twice. + const node = new rn.valueClass(); + const woken = jest.fn(); + whenMoved(node, woken); + node.__makeNative(); + expect(woken).toHaveBeenCalledTimes(1); + rn.implementation.attachNativeEvent('host', 'onScroll', [{ nativeEvent: { y: scrollY } }]); + expect(eventsOn(scrollY)).toHaveLength(2); + // Not enumerable: spreading the object copies no state. + expect(Object.getOwnPropertySymbols({ ...rn.implementation })).toEqual([]); + }); + + it('a second React Native copy\'s AnimatedEvent joins what the rest already follow', () => { + const rn = sources(); + followDrivers(rn); + const scrollY = value(); + rn.implementation.attachNativeEvent('host', 'onScroll', [{ nativeEvent: { y: scrollY } }]); + + followDrivers({ ...rn, eventClass: sources().eventClass }); + + expect(eventsOn(scrollY)).toHaveLength(1); + }); + + it('a function an app set over the wrapper (calling it) is not wrapped again: each attachment counted once', () => { + const rn = sources(); + followDrivers(rn); + const ours = rn.implementation.attachNativeEvent; + rn.implementation.attachNativeEvent = jest.fn((...args: unknown[]) => ours(...args)); + const scrollY = value(); + + followDrivers(rn); + rn.implementation.attachNativeEvent('host', 'onScroll', [{ nativeEvent: { y: scrollY } }]); + + expect(eventsOn(scrollY)).toHaveLength(1); + }); + + it('forgotten (a test resetting it), nothing is followed', () => { + followDrivers(sources()); + forgetDrivers(); + + expect(eventsOn(value())).toBeUndefined(); + expect(() => noteAnimating(value())).not.toThrow(); + expect(knownDriven(value())).toBe(false); + }); + + it('P3-2: a mapping that cannot be read still attaches, and then no value is trusted', () => { + const rn = sources(); + followDrivers(rn); + const throwing = { + get nativeEvent(): unknown { + throw new Error('getter'); + }, + }; + + expect(new rn.eventClass([throwing]).__attach('view', 'onScroll')).toBe('attached'); + expect(eventsOn(value())).toBeUndefined(); + }); + + it('a mapping attachNativeEvent cannot read, or a handle it cannot wrap: still attached, never trusted', () => { + const rn = sources(); + const original = rn.implementation.attachNativeEvent; + followDrivers(rn); + const scrollY = value(); + original.mockReturnValueOnce(Object.freeze({ detach: () => 'frozen' })); + + const frozen = rn.implementation.attachNativeEvent('host', 'onScroll', [{ nativeEvent: { y: scrollY } }]); + expect(frozen.detach()).toBe('frozen'); + expect(eventsOn(scrollY)).toHaveLength(1); + original.mockReturnValueOnce(null); + expect(rn.implementation.attachNativeEvent('host', 'onScroll', [{ nativeEvent: { y: scrollY } }])).toBeNull(); + expect(eventsOn(scrollY)).toHaveLength(2); + + rn.implementation.attachNativeEvent('host', 'onScroll', null); + expect(eventsOn(scrollY)).toHaveLength(2); + rn.implementation.attachNativeEvent('host', 'onScroll', { + get 0(): unknown { + throw new Error('getter'); + }, + }); + expect(eventsOn(scrollY)).toBeUndefined(); + }); + + it.each<[string, (rn: ReturnType) => DriverSources]>([ + ['no AnimatedEvent', (rn) => ({ ...rn, eventClass: undefined })], + ['an AnimatedEvent that does not detach', (rn) => ({ ...rn, eventClass: class { __attach(): void {} } })], + ['no AnimatedImplementation.attachNativeEvent', (rn) => ({ ...rn, implementation: {} })], + ['no AnimatedValue', (rn) => ({ ...rn, valueClass: null })], + ['an AnimatedValue that is never made native', (rn) => ({ ...rn, valueClass: class {} })], + ])('with %s, nothing is followed and nothing trusted', (_, from) => { + const rn = sources(); + followDrivers(rn); + + expect(followDrivers(from(rn))).toBe(false); + expect(eventsOn(value())).toBeUndefined(); + expect(knownDriven(new rn.valueClass())).toBe(false); + expect(whenMoved(value(), () => undefined)).toBeUndefined(); + }); + + it('without the public Animated object, the rest is still followed', () => { + expect(followDrivers({ ...sources(), exported: undefined })).toBe(true); + }); +}); + +describe('what a value is driven by', () => { + it('made native while followed: known, until an event is attached; then unknown, until seen animating', () => { + const rn = sources(); + followDrivers(rn); + const node = new rn.valueClass(); + expect(knownDriven(node)).toBe(false); + + node.__makeNative(); + expect(knownDriven(node)).toBe(true); + const event = new rn.eventClass([{ nativeEvent: { y: node } }]); + event.__attach(); + expect(knownDriven(node)).toBe(false); + event.__detach(); + expect(knownDriven(node)).toBe(false); + noteAnimating(node); + expect(knownDriven(node)).toBe(true); + }); + + it('made native before it was followed: not known', () => { + const rn = sources(); + const early = new rn.valueClass(); + early.__makeNative(); + followDrivers(rn); + // Made native again (another animation): it already was, so what drove it before is still not known. + early.__makeNative(); + + expect(knownDriven(early)).toBe(false); + }); + + it('B6, N1: wakes a watcher at once as JS moves its value, after React Native did, once a move', () => { + const rn = sources(); + followDrivers(rn); + const node = new rn.valueClass(); + const seen: unknown[] = []; + whenMoved(node, () => seen.push([node._value, node._animation])); + + expect(node.animate('timing')).toBe('animating'); + node.setValue(5); + node.resetAnimation(); + node.setOffset(); + node.flattenOffset(); + node.extractOffset(); + + // Each woke it once, with what React Native's own method had just done. + expect(seen).toEqual([[0, 'timing'], [5, 'timing'], [0, 'timing'], [0, 'timing'], [0, 'timing'], [0, 'timing']]); + expect(rn.calls.map(([name]) => name)).toEqual([ + 'animate', + 'setValue', + 'resetAnimation', + 'setOffset', + 'flattenOffset', + 'extractOffset', + ]); + // Set from JS: known from then on, as long as no event is attached. + expect(knownDriven(node)).toBe(true); + }); + + it('a value no one watches is moved with nothing to wake', () => { + const rn = sources(); + followDrivers(rn); + + expect(() => new rn.valueClass().animate('timing')).not.toThrow(); + }); + + it('P3-1: woken as React Native makes a value native inside an attach, a view is woken again once counted', () => { + const rn = sources(); + const original = rn.implementation.attachNativeEvent; + // React Native's attachNativeEventImpl makes the mapped values native before it returns. + type Mapping = Array<{ nativeEvent: { y: { __makeNative: () => void } } }>; + original.mockImplementation((_view: unknown, _event: unknown, mapping: Mapping) => { + mapping[0]?.nativeEvent.y.__makeNative(); + return { detach: jest.fn() }; + }); + followDrivers(rn); + const node = new rn.valueClass(); + const seen: unknown[] = []; + whenMoved(node, () => seen.push(eventsOn(node)?.length)); + + rn.implementation.attachNativeEvent('host', 'onScroll', [{ nativeEvent: { y: node } }]); + + // Inside the attach, not counted yet; then counted, and woken again. + expect(seen).toEqual([0, 1]); + }); + + it('P3-2: a value JS set while an event was attached is not trusted once the last event detaches', () => { + const rn = sources(); + followDrivers(rn); + const node = new rn.valueClass(); + const first = new rn.eventClass([{ nativeEvent: { y: node } }]); + const second = new rn.eventClass([{ nativeEvent: { y: node } }]); + first.__attach(); + second.__attach(); + node.setValue(0); + first.__detach(); + // Still driven by the second: trusted or not, the event wins; known until the last one goes. + expect(knownDriven(node)).toBe(true); + + second.__detach(); + + expect(knownDriven(node)).toBe(false); + }); + + it('a value set from JS after its event left it is known again', () => { + const rn = sources(); + followDrivers(rn); + const node = new rn.valueClass(); + const event = new rn.eventClass([{ nativeEvent: { y: node } }]); + event.__attach(); + event.__detach(); + expect(knownDriven(node)).toBe(false); + + node.setValue(3); + + expect(knownDriven(node)).toBe(true); + }); + + it('an AnimatedValue without the methods that move it is still followed by being made native', () => { + const rn = sources(); + class Bare { + __isNative = false; + __makeNative(): void { + this.__isNative = true; + } + } + expect(followDrivers({ ...rn, valueClass: Bare })).toBe(true); + // Nothing React Native lacks is added. + expect(Object.getOwnPropertyNames(Bare.prototype).sort()).toEqual(['__makeNative', 'constructor']); + const node = new Bare(); + const woken = jest.fn(); + whenMoved(node, woken); + + node.__makeNative(); + + expect(woken).toHaveBeenCalledTimes(1); + expect(knownDriven(node)).toBe(true); + }); + + it('P3-3: wakes a watcher the moment its value is made native, once, until it stops watching', () => { + const rn = sources(); + followDrivers(rn); + const node = new rn.valueClass(); + const woken = jest.fn(); + const failing = jest.fn(() => { + throw new Error('watcher'); + }); + whenMoved(node, failing); + const stop = whenMoved(node, woken) as () => void; + + node.__makeNative(); + node.__makeNative(); + expect(woken).toHaveBeenCalledTimes(1); + expect(failing).toHaveBeenCalledTimes(1); + // React Native's own method ran each time. + expect(rn.calls.filter(([name]) => name === 'makeNative')).toHaveLength(2); + + const other = new rn.valueClass(); + const stopOther = whenMoved(other, woken) as () => void; + stopOther(); + stop(); + other.__makeNative(); + expect(woken).toHaveBeenCalledTimes(1); + }); + + it('a node is anything with __getValue', () => { + expect(isNode({ __getValue: () => 0 })).toBe(true); + expect([null, undefined, 3, {}].map(isNode)).toEqual([false, false, false, false]); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/fakeAncestors.ts b/packages/react-native/src/secure/__tests__/fakeAncestors.ts index 157bbb0c..50029788 100644 --- a/packages/react-native/src/secure/__tests__/fakeAncestors.ts +++ b/packages/react-native/src/secure/__tests__/fakeAncestors.ts @@ -38,7 +38,23 @@ export function fabricHost( export const view = (children: FiberSpec[], props: Record = {}): FiberSpec => fabricHost('RCTView', rect(0, 0, 400, 800), props, children); +/** + * A vertical ScrollView as React Native renders it on Android: its content + * container through the `View` component (a component fiber, then the + * `RCTView` host), `VScrollContentViewNativeComponent` on 0.81-0.87. + */ export const scrollView = (measured: WindowRect | null, children: FiberSpec[], type = 'RCTScrollView'): FiberSpec => + fabricHost(type, measured, {}, [ + { + tag: FiberTag.FunctionComponent, + type: () => null, + memoizedProps: {}, + children: [fabricHost('RCTView', rect(0, -5000, 400, 9000), {}, children)], + }, + ]); + +/** A ScrollView as React Native renders it on iOS (and horizontally on Android): its content container a host. */ +export const iosScrollView = (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 => ({ diff --git a/packages/react-native/src/secure/__tests__/fakeAnimated.ts b/packages/react-native/src/secure/__tests__/fakeAnimated.ts new file mode 100644 index 00000000..b26239ec --- /dev/null +++ b/packages/react-native/src/secure/__tests__/fakeAnimated.ts @@ -0,0 +1,79 @@ +/** + * Stand-ins for the React Native `Animated` internals `followEvents.ts` + * follows: the `AnimatedEvent` class, `AnimatedImplementation` and the public + * `Animated` object (each with `attachNativeEvent`), and the `AnimatedValue` + * class. A test file mocks the four module paths with these, so the package + * follows them as it would React Native's own: + * + * jest.mock('react-native/Libraries/Animated/AnimatedEvent', () => require('./fakeAnimated').eventModule); + * + * and so on for `implementationModule`, `animatedModule` and `valueModule`. + */ + +/** React Native's `AnimatedEvent`, as far as attaching goes. */ +export class FakeAnimatedEvent { + constructor(readonly _argMapping: unknown[]) {} + // Called with the view and the event's name, as React Native's are. + __attach(...args: unknown[]): string { + return args.length >= 0 ? 'attached' : ''; + } + __detach(...args: unknown[]): string { + return args.length >= 0 ? 'detached' : ''; + } +} + +/** React Native's `attachNativeEventImpl`: a handle that detaches. */ +export function fakeAttachNativeEvent(): { detach: () => string } { + return { detach: () => 'detached' }; +} + +/** `AnimatedImplementation` (what ScrollView calls), and the public `Animated` it is spread into. */ +export const implementation = { + // Called with the view, the event's name and the mapping, as React Native's is. + attachNativeEvent: fakeAttachNativeEvent as (...args: unknown[]) => { detach: () => string }, + timing: (): void => undefined, +}; +export const animated = { ...implementation }; + +/** React Native's `AnimatedValue`: made native by an animation or an event with the native driver. */ +export class FakeAnimatedValue { + __isNative = false; + _value = 0; + _animation: unknown = null; + __makeNative(): void { + this.__isNative = true; + } + /** Starts `animation`; with the native driver, it makes the value native as it starts, as React Native's does. */ + animate(animation: { useNativeDriver?: boolean } | null): void { + this._animation = animation; + if (animation?.useNativeDriver === true) { + this.__makeNative(); + } + } + /** Stops it, back at 0 (its starting value). */ + resetAnimation(): void { + this._animation = null; + this._value = 0; + } + setValue(value: number): void { + this._animation = null; + this._value = value; + } +} + +export const eventModule = { AnimatedEvent: FakeAnimatedEvent }; +export const implementationModule = { __esModule: true, default: implementation }; +export const animatedModule = { __esModule: true, default: animated }; +export const valueModule = { __esModule: true, default: FakeAnimatedValue }; + +/** + * Makes `node` an `Animated.Value` made native as an app's would be (by its + * first animation or event with the native driver), after the package + * loaded: what drives it is known. + */ +export function madeNative(node: T): T { + Object.setPrototypeOf(node, FakeAnimatedValue.prototype); + (node as unknown as { __isNative: boolean }).__isNative = false; + (node as unknown as FakeAnimatedValue).__makeNative(); + return node; +} diff --git a/packages/react-native/src/secure/__tests__/followEvents.test.ts b/packages/react-native/src/secure/__tests__/followEvents.test.ts new file mode 100644 index 00000000..e9734116 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/followEvents.test.ts @@ -0,0 +1,83 @@ +/** + * R3-2: the package entry starts following native `Animated` events as it + * is imported, not when the app first reads `BugseeSecure` (Metro's + * `inlineRequires` evaluates a re-export only then). + */ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { parseSync, traverse } from '@babel/core'; +import type { NodePath } from '@babel/core'; +import type * as NativeAnimation from '../nativeAnimation'; +import { FakeAnimatedEvent, madeNative } from './fakeAnimated'; + +jest.mock('react-native', () => ({ + View: 'View', + PixelRatio: { get: () => 2 }, + Platform: { OS: 'android', constants: { reactNativeVersion: { major: 0, minor: 87, patch: 1 } } }, +})); +jest.mock('../../NativeBugsee', () => require('../../__mocks__/native').nativeMock); +jest.mock('react-native/Libraries/Animated/AnimatedEvent', () => require('./fakeAnimated').eventModule); +jest.mock( + 'react-native/Libraries/Animated/AnimatedImplementation', + () => require('./fakeAnimated').implementationModule, +); +jest.mock('react-native/Libraries/Animated/Animated', () => require('./fakeAnimated').animatedModule); +jest.mock('react-native/Libraries/Animated/nodes/AnimatedValue', () => require('./fakeAnimated').valueModule); +// As inlineRequires leaves it: not evaluated until the app reads it. +jest.mock('../BugseeSecure', () => ({ BugseeSecure: (): null => null })); + +it('R3-2: an event attached before the first BugseeSecure is read is counted', () => { + // The app imports the package (to launch Bugsee), then mounts a screen whose list attaches its scroll. + require('../../index'); + const scrollY = madeNative({ _value: 0, _offset: 0, _animation: null, __getValue: () => 0 }); + expect(new FakeAnimatedEvent([{ nativeEvent: { contentOffset: { y: scrollY } } }]).__attach(9, 'onScroll')).toBe( + 'attached', + ); + + // Only now does the first load what bounds native transforms. + const { nodeBound } = require('../nativeAnimation') as typeof NativeAnimation; + + expect(nodeBound(scrollY).range).toBeUndefined(); +}); + +/** + * Metro's rule for an optional dependency (`isOptionalDependency` in + * `metro/src/ModuleGraph/worker/collectDependencies.js`, 0.81-0.87): walking + * up from the `require` through at most three statements, the first block + * reached is a `try` statement's block. + */ +function optionalToMetro(path: NodePath): boolean { + let statements = 0; + let at: NodePath | null = path; + while (at !== null && statements < 3) { + if (at.isStatement()) { + if (at.node.type === 'BlockStatement') { + return at.parentPath?.node.type === 'TryStatement' && at.key === 'block'; + } + statements += 1; + } + at = at.parentPath; + } + return false; +} + +it('P3-4: every React Native internal is required where Metro takes it as optional (a moved file builds)', () => { + const source = readFileSync(join(__dirname, '..', 'followEvents.ts'), 'utf8'); + const ast = parseSync(source, { filename: 'followEvents.ts', parserOpts: { plugins: ['typescript'] } }); + const required: Array<[string, boolean]> = []; + traverse(ast as never, { + CallExpression(path) { + const [argument] = path.node.arguments; + if (path.get('callee').isIdentifier({ name: 'require' }) && argument?.type === 'StringLiteral') { + required.push([argument.value, optionalToMetro(path)]); + } + }, + }); + + expect(required).toEqual([ + ['react-native/Libraries/Animated/AnimatedEvent', true], + ['react-native/Libraries/Animated/AnimatedImplementation', true], + ['react-native/Libraries/Animated/Animated', true], + ['react-native/Libraries/Animated/nodes/AnimatedValue', true], + ]); +}); diff --git a/packages/react-native/src/secure/__tests__/measureLoop.test.ts b/packages/react-native/src/secure/__tests__/measureLoop.test.ts index c246b6db..abafbbdc 100644 --- a/packages/react-native/src/secure/__tests__/measureLoop.test.ts +++ b/packages/react-native/src/secure/__tests__/measureLoop.test.ts @@ -386,6 +386,38 @@ describe('watched nodes and parked views', () => { expect(moved).toHaveBeenCalledTimes(2); }); + it('a node watched for its props alone is never measured, and wakes when its props change', () => { + const host = { memoizedProps: { visible: true } } as never as { memoizedProps: unknown }; + where.set(host, { x: 0, y: 0, width: 10, height: 10 }); + const changed = jest.fn(); + loop.watchNode(host as never, changed, false); + frame(); + where.set(host, { x: 0, y: 5, width: 10, height: 10 }); + frame(); + expect(measured).toEqual([]); + expect(changed).not.toHaveBeenCalled(); + + host.memoizedProps = { visible: false }; + frame(); + + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('a props-only node is measured from when another subscriber wants its geometry', () => { + const content = node(); + where.set(content, { x: 0, y: 0, width: 10, height: 10 }); + loop.watchNode(content, jest.fn(), false); + const moved = jest.fn(); + loop.watchNode(content, moved); + loop.watchNode(content, jest.fn(), false); + expect(measured).toEqual([content]); + + where.set(content, { x: 0, y: 5, width: 10, height: 10 }); + frame(); + + expect(moved).toHaveBeenCalledTimes(1); + }); + 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 }); @@ -460,6 +492,15 @@ describe('watched nodes and parked views', () => { expect(jest.getTimerCount()).toBe(0); }); + it('cancels the pending frame the moment the last watched node is unwatched', () => { + const stop = loop.watchNode(node(), jest.fn()); + expect(jest.getTimerCount()).toBe(1); + + stop(); + + 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()); @@ -528,6 +569,82 @@ describe('watched nodes and parked views', () => { expect(jest.getTimerCount()).toBe(1); }); + it('reads a watched value every frame, unmeasured, and wakes on an animation starting, ending or a jump', () => { + const value: { _animation: unknown; _value: number } = { _animation: null, _value: 0 }; + const first = jest.fn(); + const second = jest.fn(); + loop.watchValue(value, first); + loop.watchValue(value, second); + frame(); + expect(first).not.toHaveBeenCalled(); + + value._animation = {}; + frame(); + expect(first).toHaveBeenCalledTimes(1); + expect(second).toHaveBeenCalledTimes(1); + frame(); + expect(first).toHaveBeenCalledTimes(1); + + value._animation = null; + value._value = 40; + frame(); + expect(first).toHaveBeenCalledTimes(2); + expect(measured).toEqual([]); + }); + + it('keeps frames running while a value is watched, and stops them, at once, when none is', () => { + const value = { _animation: null, _value: 0 }; + const stopFirst = loop.watchValue(value, jest.fn()); + const stopSecond = loop.watchValue(value, jest.fn()); + frame(); + frame(); + expect(jest.getTimerCount()).toBe(1); + + stopFirst(); + expect(jest.getTimerCount()).toBe(1); + stopSecond(); + stopSecond(); + + expect(jest.getTimerCount()).toBe(0); + }); + + it('a stale unwatch leaves a value watched again alone', () => { + const value: { _animation: unknown; _value: number } = { _animation: null, _value: 0 }; + const stop = loop.watchValue(value, jest.fn()); + stop(); + const changed = jest.fn(); + loop.watchValue(value, changed); + stop(); + + value._value = 1; + frame(); + + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('a value watched again keeps its first baseline: a change in between wakes', () => { + const value: { _animation: unknown; _value: number } = { _animation: null, _value: 0 }; + loop.watchValue(value, jest.fn()); + value._value = 5; + const changed = jest.fn(); + loop.watchValue(value, changed); + + frame(); + + expect(changed).toHaveBeenCalledTimes(1); + }); + + it('frames stop with a value unwatched while a node is still watched only once that goes too', () => { + const stopNode = loop.watchNode(node(), jest.fn()); + const stopValue = loop.watchValue({ _animation: null, _value: 0 }, jest.fn()); + stopNode(); + expect(jest.getTimerCount()).toBe(1); + + stopValue(); + + expect(jest.getTimerCount()).toBe(0); + }); + it('re-checks every parked view every tick, however many', () => { loop.addMeasurer(jest.fn()); const checks = Array.from({ length: 200 }, () => jest.fn()); diff --git a/packages/react-native/src/secure/__tests__/nativeAnimation.test.ts b/packages/react-native/src/secure/__tests__/nativeAnimation.test.ts new file mode 100644 index 00000000..8f1ddde2 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/nativeAnimation.test.ts @@ -0,0 +1,1247 @@ +/** + * `nativeAnimation.ts` bounds where native-driven transforms can draw a view, + * from the `Animated` nodes in the props of the components that render its + * ancestors. Every case it cannot bound must cover more, never less, and + * never move a mask off the view. + */ +import { ancestryOfFiber, isAnimatedComponent, isHidden, isReanimatedComponent, measurePass } from '../ancestry'; +import { nativeMotion, nodeBound, originOf, throughMotion } from '../nativeAnimation'; +import { WHOLE_SURFACE } from '../transition'; +import { FiberTag } from '../../viewtree/fiber'; +import type { FiberLike, WindowRect } from '../../viewtree/fiber'; +import type { FiberSpec } from '../../viewtree/__tests__/fakeFibers'; +import { composite, fabricHost, fakeRenderer, rect, root, scrollView, self, selfIn, view } from './fakeAncestors'; +import type { FakeRenderer } from './fakeAncestors'; +import { FakeAnimatedEvent, implementation, madeNative } from './fakeAnimated'; + +// React Native's Animated internals, followed as the package follows an app's. +jest.mock('react-native/Libraries/Animated/AnimatedEvent', () => require('./fakeAnimated').eventModule); +jest.mock( + 'react-native/Libraries/Animated/AnimatedImplementation', + () => require('./fakeAnimated').implementationModule, +); +jest.mock('react-native/Libraries/Animated/Animated', () => require('./fakeAnimated').animatedModule); +jest.mock('react-native/Libraries/Animated/nodes/AnimatedValue', () => require('./fakeAnimated').valueModule); + +let renderer: FakeRenderer; +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + getPublicInstanceFromInternalInstanceHandle: (fiber: FiberLike) => + renderer.getPublicInstanceFromInternalInstanceHandle(fiber), +})); + +beforeEach(() => { + renderer = fakeRenderer(); +}); + +const linear = (t: number): number => t; + +interface Value { + __isNative: boolean; + _value: unknown; + _offset: unknown; + _animation: unknown; + _tracking: unknown; + __getValue: () => unknown; + hasListeners: () => boolean; +} + +/** + * A native `Animated.Value`; `known`: made native after the package loaded, + * so what drives it is followed; otherwise anything may. + */ +function value(at: unknown, animation: unknown = null, offset: unknown = 0, known = true): Value { + const node: Value = { + __isNative: true, + _value: at, + _offset: offset, + _animation: animation, + _tracking: null, + __getValue: () => + typeof node._value === 'number' && typeof node._offset === 'number' ? node._value + node._offset : node._value, + hasListeners: () => known, + }; + return known ? madeNative(node) : node; +} + +const timing = (from: unknown, to: unknown, easing: unknown = linear): object => ({ + _fromValue: from, + _toValue: to, + _easing: easing, +}); + +function interpolated(parent: unknown, config: Record, map: unknown): object { + return { + __isNative: true, + _parent: parent, + _config: config, + _getInterpolation: map === undefined ? undefined : () => map, + __getValue: () => 0, + }; +} + +type Operation = 'add' | 'subtract' | 'multiply' | 'divide' | 'other'; +const COMPUTE: Record number> = { + add: (x, y) => x + y, + subtract: (x, y) => x - y, + multiply: (x, y) => x * y, + divide: (x, y) => x / y, + other: (x, y) => Math.max(x, y) + 0.5, +}; + +/** + * A stand-in for `Animated.add(a, b)` (or `operation`): React Native wraps a + * number operand in a value, and computes the node's value from both. + */ +function binary(a: unknown, b: unknown, extra: Record = {}, operation: Operation = 'add'): object { + const operand = (x: unknown): { __getValue: () => unknown } => + (typeof x === 'number' ? value(x) : x) as { __getValue: () => unknown }; + const left = operand(a); + const right = operand(b); + return { + __isNative: true, + _a: left, + _b: right, + __getValue: () => COMPUTE[operation](left.__getValue() as number, right.__getValue() as number), + ...extra, + }; +} + +describe('nodeBound', () => { + it('a number is itself; anything else that is not a node is unbounded', () => { + expect(nodeBound(7)).toEqual({ range: { lo: 7, hi: 7 }, running: false, roots: [] }); + expect(nodeBound('7').range).toBeUndefined(); + expect(nodeBound(null).range).toBeUndefined(); + }); + + it('a still value JS provably holds is where it is, offset included', () => { + const node = value(3, null, 2); + + expect(nodeBound(node)).toEqual({ range: { lo: 5, hi: 5 }, running: false, roots: [node] }); + }); + + it('R3-1: a still native value whose driver is not known (made native before the package) is unbounded', () => { + const node = value(3, null, 0, false); + + expect(nodeBound(node)).toEqual({ range: undefined, running: false, roots: [node] }); + }); + + it('a value driven from JS (not native) is where JS has it, whatever is followed', () => { + const node = { ...value(3, null, 0, false), __isNative: false }; + + expect(nodeBound(node).range).toEqual({ lo: 3, hi: 3 }); + }); + + it('R3-1: a value of unknown driver seen animating is known from then on: still, at its end', () => { + const node = value(0, timing(0, 10), 0, false); + expect(nodeBound(node).running).toBe(true); + + node._animation = null; + node._value = 10; + expect(nodeBound(node).range).toEqual({ lo: 10, hi: 10 }); + }); + + describe('native events attached', () => { + const still = (): Value => value(0); + + it('N2: drag, spring back, drag again: unbounded throughout; detached, unknown until an animation', () => { + const drag = still(); + // The gesture's Animated.event, attached as the Animated component mounts. + const event = new FakeAnimatedEvent([{ nativeEvent: { translationX: drag } }]); + expect(event.__attach('view', 'onGestureEvent')).toBe('attached'); + expect(nodeBound(drag).range).toBeUndefined(); + + // Released: a spring back, then still at its end, then dragged again. + drag._animation = { _toValue: 0 }; + expect(nodeBound(drag).range).toBeUndefined(); + drag._animation = null; + expect(nodeBound(drag).range).toBeUndefined(); + drag._animation = timing(0, 0); + expect(nodeBound(drag)).toEqual({ range: undefined, running: true, roots: [drag] }); + drag._animation = null; + expect(nodeBound(drag).range).toBeUndefined(); + + // Where the event left it, JS may not know. + expect(event.__detach('view', 'onGestureEvent')).toBe('detached'); + expect(nodeBound(drag).range).toBeUndefined(); + drag._animation = timing(0, 0); + nodeBound(drag); + drag._animation = null; + expect(nodeBound(drag).range).toEqual({ lo: 0, hi: 0 }); + }); + + it('R3-1: a value React Native\'s ScrollView attaches directly (sticky headers) is unbounded', () => { + const scrollY = still(); + const sticky = interpolated( + scrollY, + { inputRange: [-1, 0, 300, 301], outputRange: [0, 0, 0, 1] }, + (input: number) => Math.max(0, input - 300), + ); + implementation.attachNativeEvent({}, 'onScroll', [{ nativeEvent: { contentOffset: { y: scrollY } } }]); + + expect(nodeBound(sticky).range).toBeUndefined(); + }); + }); + + it('a timing animation covers from its start to its end, offset included, and runs', () => { + expect(nodeBound(value(0, timing(10, -20), 1))).toEqual( + expect.objectContaining({ range: { lo: -19, hi: 11 }, running: true }), + ); + }); + + it('an easing that turns back is widened: a peak can fall between samples', () => { + const back = (t: number): number => t * t * (2.2 * t - 1.2); + const range = nodeBound(value(0, timing(0, 100, back))).range as { lo: number; hi: number }; + + expect(range.lo).toBeLessThan(-10); + expect(range.hi).toBeGreaterThan(100); + }); + + it.each<[string, unknown]>([ + ['renamed fields', { __isNative: true, _val: 0, _offset: 0, _animation: null, __getValue: () => 0 }], + ['no _offset', { __isNative: true, _value: 0, _animation: null, __getValue: () => 0 }], + ['no _value', { __isNative: true, _offset: 0, _animation: null, __getValue: () => 0 }], + ])('a value with %s is unbounded', (_, node) => { + expect(nodeBound(node).range).toBeUndefined(); + }); + + it.each<[string, Value]>([ + ['a non-finite value', value(Number.NaN)], + ['a non-finite offset', value(1, null, Number.NaN)], + ['a tracked value', Object.assign(value(1), { _tracking: {} })], + ])('%s is unbounded', (_, node) => { + expect(nodeBound(node).range).toBeUndefined(); + }); + + it.each<[string, Value]>([ + ['a spring (no start)', value(0, { _toValue: 1, _easing: linear })], + ['an animation with no easing', value(0, { _fromValue: 0, _toValue: 1 })], + ['an easing that answers NaN', value(0, timing(0, 1, () => Number.NaN))], + ])('%s runs, unbounded', (_, node) => { + expect(nodeBound(node)).toEqual(expect.objectContaining({ range: undefined, running: true })); + }); + + it('an interpolation maps every value its input can take, breakpoints included', () => { + const peak = (x: number): number => (x <= 0.33 ? (x / 0.33) * 100 : ((1 - x) / 0.67) * 100); + const node = interpolated(value(0, timing(0, 1)), { inputRange: [0, 0.33, 1, 'x', -1, 2] }, peak); + + const range = nodeBound(node).range as { lo: number; hi: number }; + expect(range.lo).toBeLessThanOrEqual(0); + expect(range.hi).toBeGreaterThanOrEqual(100); + }); + + it('an interpolation with no config at all still maps its input', () => { + const node = { ...interpolated(value(2, timing(2, 4)), {}, (x: number) => x * 2), _config: undefined }; + + expect(nodeBound(node).range).toEqual({ lo: 4, hi: 8 }); + }); + + it('a monotonic interpolation is not widened', () => { + expect(nodeBound(interpolated(value(2, timing(2, 4)), {}, (x: number) => x)).range).toEqual({ lo: 2, hi: 4 }); + }); + + it('an interpolation clamped at both ends is within its outputs, whatever its input', () => { + const unknown = value(0, null, 0, false); + const clamped = interpolated(unknown, { outputRange: [0, -60, 20], extrapolate: 'clamp' }, (x: number) => x); + const sides = interpolated( + unknown, + { outputRange: [5, 9], extrapolateLeft: 'clamp', extrapolateRight: 'clamp' }, + undefined, + ); + + expect(nodeBound(clamped).range).toEqual({ lo: -60, hi: 20 }); + expect(nodeBound(sides).range).toEqual({ lo: 5, hi: 9 }); + }); + + it.each<[string, Record]>([ + ['clamped on one side only', { outputRange: [0, 1], extrapolateLeft: 'clamp' }], + ['with string outputs', { outputRange: ['0deg', '9deg'], extrapolate: 'clamp' }], + ['with no outputs', { extrapolate: 'clamp' }], + ])('an interpolation of an unbounded input %s is unbounded', (_, config) => { + expect(nodeBound(interpolated(value(0, null, 0, false), config, linear)).range).toBeUndefined(); + }); + + describe('R3-4: an interpolation of an unbounded input, by its ends', () => { + const of = (config: Record): unknown => + nodeBound(interpolated(value(0, null, 0, false), config, linear)).range; + + it('extending past both ends along flat last segments (a swipeable row): within its outputs', () => { + expect(of({ inputRange: [-101, -100, 80, 81], outputRange: [-100, -100, 80, 80] })).toEqual({ lo: -100, hi: 80 }); + // Flat on one side, clamped on the other. + expect(of({ outputRange: [3, 3, 9], extrapolateRight: 'clamp' })).toEqual({ lo: 3, hi: 9 }); + }); + + it.each<[string, Record]>([ + ['extending a sloped end', { outputRange: [0, 0, 1] }], + ['extending a sloped first end', { outputRange: [0, 1, 1] }], + ['passing its input through at a flat end', { outputRange: [0, 0, 1, 1], extrapolateLeft: 'identity' }], + ['passing it through at the other', { outputRange: [0, 0, 1, 1], extrapolateRight: 'identity' }], + // React Native reads a side set to null as set, and extends it. + ['a side null under an overall clamp', { outputRange: [0, 1], extrapolate: 'clamp', extrapolateLeft: null }], + ['one output', { outputRange: [4], extrapolate: 'clamp' }], + ['an easing that is not a function', { outputRange: [0, 1], extrapolate: 'clamp', easing: 'linear' }], + ['an easing that answers no number', { outputRange: [0, 1], extrapolate: 'clamp', easing: () => Number.NaN }], + [ + 'an easing with no number at its end', + { outputRange: [0, 1], extrapolate: 'clamp', easing: (t: number) => (t < 1 ? t : Number.NaN) }, + ], + ])('%s: unbounded', (_, config) => { + expect(of(config)).toBeUndefined(); + }); + + it('a side set to undefined takes the overall setting', () => { + expect(of({ outputRange: [0, 1], extrapolate: 'clamp', extrapolateLeft: undefined })).toEqual({ lo: 0, hi: 1 }); + }); + + it('eased, each sloped segment spans what its easing reaches, overshoot included; a flat one stays put', () => { + // An easing that dips below its start (a "back" ease), sampled and widened as it turns. + const back = (t: number): number => t * t * (2.2 * t - 1.2); + const range = of({ outputRange: [10, 10, 20], extrapolate: 'clamp', easing: back }) as { lo: number; hi: number }; + + // Its dip (-0.05 of the segment), padded by a tenth of its span as the curve turns back. + expect(range.lo).toBeLessThan(9.5); + expect(range.lo).toBeGreaterThan(8); + expect(range.hi).toBeGreaterThan(20); + expect(range.hi).toBeLessThan(21.5); + expect(of({ outputRange: [10, 20], extrapolate: 'clamp', easing: null })).toEqual({ lo: 10, hi: 20 }); + // An easing whose span starts above 0: a falling segment is reached from its own start. + expect(of({ outputRange: [20, 10], extrapolate: 'clamp', easing: (t: number) => 0.5 + t / 2 })).toEqual({ + lo: 10, + hi: 15, + }); + }); + }); + + it('an interpolation answering something that is not a number falls back to its clamp, or nothing', () => { + const input = value(0, timing(0, 1)); + + expect(nodeBound(interpolated(input, {}, () => '1deg')).range).toBeUndefined(); + expect(nodeBound(interpolated(input, { outputRange: [0, 4], extrapolate: 'clamp' }, () => 'x')).range).toEqual({ + lo: 0, + hi: 4, + }); + }); + + it('diffClamp is within its bounds, whatever its input; its input still counts as running', () => { + const input = value(0, timing(0, 500)); + + expect(nodeBound({ __isNative: true, _a: input, _min: 0, _max: 60, __getValue: () => 0 })).toEqual( + expect.objectContaining({ range: { lo: 0, hi: 60 }, running: true }), + ); + expect(nodeBound({ __isNative: true, _a: input, _min: 'x', _max: 60, __getValue: () => 0 }).range).toBeUndefined(); + }); + + it('modulo is between 0 and its modulus', () => { + const input = value(0, null, 0, false); + + const negative = { __isNative: true, _a: input, _modulus: -8, __getValue: () => 0 }; + expect(nodeBound(negative).range).toEqual({ lo: -8, hi: 0 }); + expect(nodeBound({ __isNative: true, _a: input, _modulus: null, __getValue: () => 0 }).range).toBeUndefined(); + }); + + it('add, subtract and multiply are each bounded by intervals over both operands', () => { + const a = (): Value => value(1, timing(1, 2)); + + expect(nodeBound(binary(a(), 3)).range).toEqual({ lo: 4, hi: 5 }); + expect(nodeBound(binary(a(), 3, {}, 'subtract')).range).toEqual({ lo: -2, hi: -1 }); + expect(nodeBound(binary(a(), 3, {}, 'multiply')).range).toEqual({ lo: 3, hi: 6 }); + }); + + it('P3-a: a division that lost its own field is told by what it computes, and bounded as one', () => { + // 4 / b, b from 2 down to 0.5 (now 2): 2..8, which add, subtract or multiply would bound at most at 8 - 0.5. + const node = binary(value(4), value(2, timing(2, 0.5)), {}, 'divide'); + + expect(nodeBound(node).range).toEqual({ lo: 2, hi: 8 }); + }); + + it('P3-a: where several operations compute the same now, each is taken', () => { + // x * 1 is x / 1 is x - 0... b is 1 now, from 1 to 0.5: x * b 1..4, x / b 2..8 (x 2..4). + const node = binary(value(2, timing(2, 4)), value(1, timing(1, 0.5)), {}, 'multiply'); + + expect(nodeBound(node).range).toEqual(expect.objectContaining({ lo: expect.any(Number), hi: 8 })); + }); + + it('P3-a: a binary node computing none of them is unbounded; one with a number operand too', () => { + expect(nodeBound(binary(value(1), value(2), {}, 'other')).range).toBeUndefined(); + expect(nodeBound({ __isNative: true, _a: value(1), _b: 2, __getValue: () => 3 }).range).toBeUndefined(); + expect(nodeBound({ __isNative: true, _a: 1, _b: value(2), __getValue: () => 3 }).range).toBeUndefined(); + }); + + it('a division is bounded unless its divisor can be zero; an unbounded operand is unbounded', () => { + const division = { _warnedAboutDivideByZero: false }; + + expect(nodeBound(binary(value(4), value(2, timing(2, 4)), division)).range).toEqual({ lo: 1, hi: 2 }); + expect(nodeBound(binary(value(4), value(0, timing(-1, 1)), division)).range).toBeUndefined(); + expect(nodeBound(binary(value(4, null, 0, false), 2)).range).toBeUndefined(); + }); + + it('computes a node once per measure pass', () => { + const getter = jest.fn(() => 1); + const node = { ...value(1), __getValue: getter }; + measurePass(() => { + nodeBound(node); + nodeBound(node); + }); + + expect(getter).toHaveBeenCalledTimes(1); + }); +}); + +describe('originOf', () => { + const style = (transformOrigin: unknown): unknown => ({ style: { transformOrigin } }); + const at = (fraction: number, length = 0): object => ({ fraction, length }); + + it.each<[string, unknown, object | undefined]>([ + ['unset: the centre', undefined, { x: at(0.5), y: at(0.5) }], + ['keywords', 'top left', { x: at(0), y: at(0) }], + ['keywords the CSS way round', 'right bottom', { x: at(1), y: at(1) }], + ['one vertical keyword', 'bottom', { x: at(0.5), y: at(1) }], + ['one horizontal keyword', 'left', { x: at(0), y: at(0.5) }], + ['percentages and lengths', '25% 10px', { x: at(0.25), y: at(0, 10) }], + ['an array, with a z', [5, '50%', 3], { x: at(0, 5), y: at(0.5) }], + ['a calc()', 'calc(50% - 2px) top', undefined], + ['words it does not know', 'middle top', undefined], + ['spaces around', ' left top ', { x: at(0), y: at(0) }], + ['a vertical keyword first', 'top 25%', { x: at(0.25), y: at(0) }], + ['a horizontal keyword second', 'center left', { x: at(0), y: at(0.5) }], + ['a right keyword second', '10px right', { x: at(1), y: at(0, 10) }], + ['a length before its unit is all', 'x10px', undefined], + ['a length with more after it', '10pxy', undefined], + ['a fraction', '12.25%', { x: at(0.1225), y: at(0.5) }], + ['a fraction with no digits after the point', '1.x', undefined], + ['a plain number in a string', '10 20', { x: at(0, 10), y: at(0, 20) }], + ['an unknown second word', 'left blah', undefined], + ['four values', '1 2 3 4', undefined], + ['an empty array', [], undefined], + ['an object', { x: 1 }, undefined], + ['an array with an object', [{}, 1], undefined], + ])('%s', (_, origin, expected) => { + expect(originOf(style(origin))).toEqual(expected); + }); + + it('props without a style: the centre', () => { + expect(originOf(null)).toEqual({ x: at(0.5), y: at(0.5) }); + }); +}); + +describe('isAnimatedComponent and isReanimatedComponent', () => { + it.each<[string, unknown, boolean, boolean]>([ + ['a function named Animated(…)', Object.assign(() => null, { displayName: 'Animated(View)' }), true, false], + ['a forwardRef named Animated(…)', { render: { displayName: 'Animated(Text)' } }, true, false], + ['a Reanimated component', Object.assign(() => null, { displayName: 'AnimatedComponent(View)' }), true, true], + ['any other name', Object.assign(() => null, { displayName: 'View' }), false, false], + ['no name', () => null, false, false], + ['a non-string name', { displayName: 7 }, false, false], + ['a host type', 'RCTView', false, false], + ['no type', null, false, false], + ])('%s', (_, type, animated, reanimated) => { + expect(isAnimatedComponent({ type } as FiberLike)).toBe(animated); + expect(isReanimatedComponent({ type } as FiberLike)).toBe(reanimated); + }); +}); + +const AnimatedView = Object.assign(() => null, { displayName: 'Animated(View)' }); + +/** The ancestry of a view under an `Animated.View` styled `style`, over a host rendered with `rendered`. */ +function under( + style: unknown, + rendered: unknown = {}, + hostRect: WindowRect | null = rect(0, 0, 400, 400), + between: (spec: FiberSpec) => FiberSpec = (spec) => spec, +): ReturnType { + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style }, + children: [between(fabricHost('RCTView', hostRect, { style: rendered }, [view([self()])]))], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + return ancestry; +} + +const SELF = rect(10, 100, 200, 50); + +/** Where the first level moves SELF, or `undefined`. */ +function moved(ancestry: ReturnType): WindowRect | undefined { + return nativeMotion(ancestry)?.levels[0]?.move(SELF); +} + +describe('nativeMotion', () => { + it('nothing Animated above the view: nothing to do', () => { + expect(nativeMotion(ancestryOfFiber(selfIn(root([view([self()])]))))).toBeNull(); + }); + + it.each<[string, unknown]>([ + ['no style', undefined], + ['a style without a transform', { opacity: value(1) }], + ['a transform of plain numbers', { transform: [{ translateX: 10 }] }], + ['a transform that is not a list', { transform: 'translateX(10px)' }], + ])('an Animated component with %s moves nothing natively', (_, style) => { + expect(nativeMotion(under(style))).toBeNull(); + }); + + it('N1: a transform of values not native yet moves nothing, and its values are watched', () => { + // The first animation with the native driver makes them native, and commits nothing. + const node = { ...value(10), __isNative: false }; + const interpolatedNode = { ...interpolated(node, {}, linear), __isNative: false }; + + expect(nativeMotion(under({ transform: [{ translateX: node }, { translateY: interpolatedNode }] }))).toEqual({ + levels: [], + running: false, + roots: [node, node], + }); + }); + + it('N1: nodes not native yet with nothing to watch keep the view measured every frame', () => { + const unknown = { __isNative: false, __getValue: () => 0 }; + + expect(nativeMotion(under({ transform: [{ translateX: unknown }] }))).toEqual({ + levels: [], + running: true, + roots: [], + }); + }); + + it('a node without the native flag at all is taken as native', () => { + const node = value(5) as Partial; + delete node.__isNative; + + expect(moved(under({ transform: [{ translateX: node }] }, { transform: [{ translateX: 0 }] }))).toEqual( + rect(10, 100, 205, 50), + ); + }); + + it('joins where the view is with every place the value can take it, from either value in the shadow tree', () => { + // Rendered at 10, now at 30: the shadow tree holds 10 (moved +20) or 30 (where measured). + expect(moved(under({ transform: [{ translateX: value(30) }] }, { transform: [{ translateX: 10 }] }))).toEqual( + rect(10, 100, 220, 50), + ); + }); + + it('without the rendered value, uses the current one alone', () => { + expect(moved(under({ transform: [{ translateX: value(30, timing(30, 50)) }] }, {}))).toEqual( + rect(10, 100, 220, 50), + ); + }); + + it('composes a translation after a scale, about the centre', () => { + const ancestry = under( + { transform: [{ translateY: value(0, timing(0, -20)) }, { scale: 2 }] }, + { transform: [{ translateY: 0 }, { scale: 2 }] }, + ); + + // Measured at twice the size; its frame (half) carried back to twice, and up by up to 20. + expect(moved(ancestry)).toEqual(rect(10, 80, 200, 70)); + }); + + it('a node bounded but not readable now is measured from the rendered value alone', () => { + const clamp = { __isNative: true, _a: value(0, null, 0, false), _min: 0, _max: 10, __getValue: () => Number.NaN }; + + expect(moved(under({ transform: [{ translateX: clamp }] }, { transform: [{ translateX: 0 }] }))).toEqual( + rect(10, 100, 210, 50), + ); + }); + + it('a static rotation of nothing alongside is fine; any other is unbounded', () => { + expect( + moved(under({ transform: [{ translateX: value(5) }, { rotate: '0deg' }] }, { transform: [{ translateX: 0 }] })), + ).toEqual(rect(10, 100, 205, 50)); + expect( + moved(under({ transform: [{ translateX: value(5) }, { rotate: '9deg' }] }, { transform: [{ translateX: 0 }] })), + ).toBeUndefined(); + }); + + it.each<[string, unknown, unknown]>([ + ['a translation it cannot bound', { transform: [{ translateX: value(0, null, 0, false) }] }, {}], + ['an entry that is not an object', { transform: [{ translateX: value(1) }, null] }, {}], + ['a scale to nothing in the shadow tree', { transform: [{ scale: value(0) }] }, { transform: [{ scale: 0 }] }], + ])('%s is unbounded', (_, style, rendered) => { + expect(moved(under(style, rendered))).toBeUndefined(); + }); + + it('a host that cannot be measured is unbounded', () => { + expect(moved(under({ transform: [{ scale: value(2) }] }, { transform: [{ scale: 1 }] }, null))).toBeUndefined(); + }); + + it('reading the transform throws: unbounded', () => { + const throwing = value(1); + Object.defineProperty(throwing, '_animation', { + get(): never { + throw new Error('torn'); + }, + }); + + expect(moved(under({ transform: [{ translateX: throwing }] }, { transform: [{ translateX: 0 }] }))).toBeUndefined(); + }); + + it('a move that throws is unbounded', () => { + renderer.getPublicInstanceFromInternalInstanceHandle = () => { + throw new Error('gone'); + }; + + expect(moved(under({ transform: [{ translateX: value(1) }] }, { transform: [{ translateX: 0 }] }))).toBeUndefined(); + }); + + it('reports running animations and the native values to watch', () => { + const still = value(1); + const moving = value(0, timing(0, 5)); + const motion = nativeMotion(under({ transform: [{ translateX: still }, { translateY: moving }] })); + + expect(motion?.running).toBe(true); + expect(motion?.roots).toEqual([still, moving]); + }); + + it('nested Animated components, nearest first; one right above the view transforms the view itself', () => { + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: value(100) }] } }, + children: [ + fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: [{ translateY: 0 }] } }, [ + composite([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateX: value(5) }] } }, + children: [{ ...self(), memoizedProps: { style: { transform: [{ translateX: 0 }] } } }], + }, + ]), + ]), + ], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + renderer.getPublicInstanceFromInternalInstanceHandle = (target) => ({ + measureInWindow: (callback: (x: number, y: number, w: number, h: number) => void) => { + const at = (target.stateNode as { rect?: WindowRect } | null)?.rect ?? SELF; + callback(at.x, at.y, at.width, at.height); + }, + }); + const motion = nativeMotion(ancestry); + + expect(motion?.levels.map((level) => level.index)).toEqual(ancestry.animated); + expect(throughMotion(SELF, ancestry, motion!).drawn).toEqual(rect(10, 100, 205, 150)); + }); + + it('P3-c: a function that comes twice takes the rendered value of its own place', () => { + const transform = [{ translateX: value(10) }, { translateX: value(0, timing(0, 50)) }]; + + expect(moved(under({ transform }, { transform: [{ translateX: 10 }, { translateX: 0 }] }))).toEqual( + rect(10, 100, 250, 50), + ); + // Rendered with other functions in its places: only the value it holds now is a base. + expect(moved(under({ transform }, { transform: [{ translateY: 40 }, { translateX: 0 }] }))).toEqual( + rect(10, 100, 250, 50), + ); + }); + + it.each<[string, unknown, boolean]>([ + ['scaled up', 2, false], + ['mirrored and scaled up', -2, false], + ['scaled by a value now at 2', value(2), false], + ['scaled by what cannot be read', 'big', false], + ['scaled down', 0.5, true], + ['not scaled', 1, true], + ])('inside a plain host %s, a native level is bounded only if that scale is not up', (_, scale, bounded) => { + const fiber = selfIn( + root([ + fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: [{ translateY: 5 }, { scale }] } }, [ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateX: value(0, timing(0, 50)) }] } }, + children: [ + // Its own host: inside the level, its transform is the level's own business. + fabricHost( + 'RCTView', + rect(0, 0, 400, 400), + { style: { transform: [{ translateX: 0 }, { scaleX: 1 }] } }, + [view([self()])], + ), + ], + }, + ]), + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + + expect(moved(ancestry)).toEqual(bounded ? rect(10, 100, 250, 50) : undefined); + }); + + it('P3-b: inside a Reanimated level (unbounded anyway), an inner level keeps its own bound', () => { + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: Object.assign(() => null, { displayName: 'AnimatedComponent(View)' }), + memoizedProps: { style: { transitionProperty: 'transform' } }, + children: [ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateX: value(0, timing(0, 50)) }] } }, + children: [ + fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: [{ translateX: 0 }] } }, [ + view([self()]), + ]), + ], + }, + ], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + + expect(moved(ancestry)).toEqual(rect(10, 100, 250, 50)); + }); + + it('N1: one native node among nodes not native yet is a level', () => { + const transform = [{ translateX: { ...value(0), __isNative: false } }, { translateY: value(0, timing(0, 20)) }]; + + expect(moved(under({ transform }, { transform: [{ translateX: 0 }, { translateY: 0 }] }))).toEqual( + rect(10, 100, 200, 70), + ); + }); + + /** + * A view under an inner `Animated.View` translated by `inner`, inside one + * scaled by `scale` (after a still translation, with `lifted`), rendered at `at`. + */ + function scaledOuter(scale: unknown, at: number, inner: unknown, lifted = false): ReturnType { + const lift = lifted ? [{ translateY: value(0) }] : []; + const liftRendered = lifted ? [{ translateY: 0 }] : []; + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [...lift, { scale }] } }, + children: [ + fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: [...liftRendered, { scale: at }] } }, [ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateX: inner }] } }, + children: [ + fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: [{ translateX: 0 }] } }, [ + view([self()]), + ]), + ], + }, + ]), + ], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + return ancestry; + } + + it('P3-b: inside a level the shadow tree scales up, an inner level is unbounded; scaled down, bounded', () => { + const slide = (): Value => value(0, timing(0, 50)); + + expect(moved(scaledOuter(value(2), 2, slide()))).toBeUndefined(); + expect(moved(scaledOuter(value(-1.5), -1.5, slide()))).toBeUndefined(); + // Not rendered with a scale: the one its value holds now is enough. + expect(moved(scaledOuter(value(2), undefined as never, slide()))).toBeUndefined(); + expect(moved(scaledOuter(value(0.5), 0.5, slide()))).toEqual(rect(10, 100, 250, 50)); + expect(moved(scaledOuter(value(1), 1, slide()))).toEqual(rect(10, 100, 250, 50)); + // Scaled up after a translation: still scaled. + expect(moved(scaledOuter(value(2), 2, slide(), true))).toBeUndefined(); + // Scaled up only by the value it holds now (C++ Animated keeps an animation's end in the shadow tree, + // not in props). + expect(moved(scaledOuter(value(2), 1, slide()))).toBeUndefined(); + expect(moved(scaledOuter(value(2), 1, slide(), true))).toBeUndefined(); + // The outer level itself is bounded either way. + expect(nativeMotion(scaledOuter(value(2), 2, slide()))?.levels[1]?.move(SELF)).toBeDefined(); + }); + + it('reads the stored path when the committed tree cannot be resolved', () => { + const ancestry = under({ transform: [{ translateX: value(5) }] }, { transform: [{ translateX: 0 }] }); + ancestry.resolved = undefined; + + expect(moved(ancestry)).toEqual(rect(10, 100, 205, 50)); + }); + + it('a Reanimated component with no animated transform is no level', () => { + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: Object.assign(() => null, { displayName: 'AnimatedComponent(View)' }), + memoizedProps: { style: [{ viewDescriptors: {}, initial: { value: { opacity: 0 } } }] }, + children: [view([self()])], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + + expect(nativeMotion(ancestry)).toBeNull(); + }); +}); + +describe('throughMotion', () => { + it('an unbounded level draws the view anywhere: the whole surface, cut by the clippers above', () => { + const wrapped = ancestryOfFiber( + selfIn( + root([ + scrollView(rect(0, 50, 400, 300), [ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ rotate: value(1) }] } }, + children: [view([self()])], + }, + ]), + ]), + ), + ); + isHidden(wrapped); + + const { drawn, visible } = throughMotion(SELF, wrapped, nativeMotion(wrapped)!); + expect(drawn).toEqual(WHOLE_SURFACE); + expect(visible).toEqual(rect(0, 50, 400, 300)); + }); + + it('a view cut away inside the transform is drawn nowhere, however it moves', () => { + const ancestry = under( + { transform: [{ translateY: value(0, timing(0, 300)) }] }, + { transform: [{ translateY: 0 }] }, + undefined, + (spec) => scrollView(rect(0, 0, 400, 50), [spec]), + ); + + expect(throughMotion(SELF, ancestry, nativeMotion(ancestry)!).visible).toBeNull(); + }); +}); + +describe('mutation-tight bounds', () => { + it('UNBOUNDED is exactly that: no range, not running, no roots', () => { + expect(nodeBound('7')).toEqual({ range: undefined, running: false, roots: [] }); + expect(nodeBound({})).toEqual({ range: undefined, running: false, roots: [] }); + }); + + it('a value missing one field it reads, the app listening, is still unbounded, with no roots', () => { + const noValue = { __isNative: true, _offset: 0, _animation: null, __getValue: () => 0, hasListeners: () => true }; + const noOffset = { __isNative: true, _value: 0, _animation: null, __getValue: () => 0, hasListeners: () => true }; + + expect(nodeBound(noValue)).toEqual({ range: undefined, running: false, roots: [] }); + expect(nodeBound(noOffset)).toEqual({ range: undefined, running: false, roots: [] }); + }); + + it('a non-finite value keeps itself as a root to watch', () => { + const node = value(Number.NaN); + + expect(nodeBound(node)).toEqual({ range: undefined, running: false, roots: [node] }); + }); + + it('a value with no hasListeners at all, never seen animating, is unbounded', () => { + const node = value(1, null, 0, false) as Partial; + delete node.hasListeners; + + expect(nodeBound(node).range).toBeUndefined(); + }); + + it('a spring keeps itself as a root; one with no easing too', () => { + const spring = value(0, { _toValue: 1, _easing: linear }); + const nan = value(0, timing(0, 1, () => Number.NaN)); + + expect(nodeBound(spring).roots).toEqual([spring]); + expect(nodeBound(nan).roots).toEqual([nan]); + }); + + it('a value now outside its running animation\'s path is covered where it is too', () => { + expect(nodeBound(value(50, timing(0, 10))).range).toEqual({ lo: 0, hi: 50 }); + }); + + it('an easing that turns back is widened by a tenth of its span on each side', () => { + // 0 -> 100 -> 50 over the animation: hull 0..100, which does not end at an end. + const turn = (t: number): number => (t <= 0.5 ? 2 * t : 1.5 - t); + + expect(nodeBound(value(0, timing(0, 100, turn))).range).toEqual({ lo: -10, hi: 110 }); + }); + + it('the widening is a tenth of the span, away from zero too', () => { + const turn = (t: number): number => (t <= 0.5 ? 2 * t : 1.5 - t); + + expect(nodeBound(value(50, timing(50, 150, turn))).range).toEqual({ lo: 40, hi: 160 }); + }); + + it('breakpoints outside what the input can take are not sampled', () => { + const map = (x: number): number => (x < 0 || x > 1 ? 1000 : x); + const node = interpolated(value(0, timing(0, 1)), { inputRange: [-5, 0, 0.5, 1, 5] }, map); + + expect(nodeBound(node).range).toEqual({ lo: 0, hi: 1 }); + }); + + it('clamped with no outputs at all is not bounded by them', () => { + const node = interpolated(value(0, null, 0, false), { outputRange: [], extrapolate: 'clamp' }, linear); + + expect(nodeBound(node).range).toBeUndefined(); + }); + + it('a division by a range that stays below zero is bounded', () => { + const node = binary(value(4), value(-2, timing(-2, -1)), { _warnedAboutDivideByZero: false }); + + expect(nodeBound(node).range).toEqual({ lo: -4, hi: -2 }); + }); + + it('a division by zero keeps both operands as roots', () => { + const a = value(4); + const b = value(0, timing(-1, 1)); + + expect(nodeBound(binary(a, b, { _warnedAboutDivideByZero: false }))).toEqual({ + range: undefined, + running: true, + roots: [a, b], + }); + }); + + it('a spike at a breakpoint between samples is found, and widened', () => { + const spike = (x: number): number => (x === 0.33 ? 100 : 0); + const node = interpolated(value(0, timing(0, 1)), { inputRange: [0, 0.32, 0.33, 0.34, 1] }, spike); + + expect(nodeBound(node).range).toEqual({ lo: -10, hi: 110 }); + }); + + it('a monotonic interpolation with breakpoints is not widened (samples in order)', () => { + const node = interpolated(value(0, timing(0, 1)), { inputRange: [0, 0.5, 1] }, (x: number) => 100 * x); + + expect(nodeBound(node).range).toEqual({ lo: 0, hi: 100 }); + }); + + it('clamped on the right only is not clamped', () => { + const node = interpolated(value(0, null, 0, false), { outputRange: [0, 1], extrapolateRight: 'clamp' }, linear); + + expect(nodeBound(node).range).toBeUndefined(); + }); + + it('clamped with an output that is not a number is not bounded by its outputs', () => { + const node = interpolated(value(0, null, 0, false), { outputRange: [0, '1'], extrapolate: 'clamp' }, linear); + + expect(nodeBound(node).range).toBeUndefined(); + }); + + it('a bounded input with no interpolation function is unbounded, without throwing', () => { + expect(nodeBound(interpolated(value(0, timing(0, 1)), {}, undefined)).range).toBeUndefined(); + }); + + it('an interpolation answering NaN for some inputs only is unbounded', () => { + const partly = (x: number): number => (x > 0.5 ? Number.NaN : x); + + expect(nodeBound(interpolated(value(0, timing(0, 1)), {}, partly)).range).toBeUndefined(); + }); + + it('add, subtract and multiply: the hull of every corner', () => { + // a 2..4, b -0.5..1 (now -0.5, where no two of them agree). + const a = (): Value => value(2, timing(2, 4)); + const b = (): Value => value(-0.5, timing(-0.5, 1)); + + expect(nodeBound(binary(a(), b())).range).toEqual({ lo: 1.5, hi: 5 }); + expect(nodeBound(binary(a(), b(), {}, 'subtract')).range).toEqual({ lo: 1, hi: 4.5 }); + expect(nodeBound(binary(a(), b(), {}, 'multiply')).range).toEqual({ lo: -2, hi: 4 }); + }); + + it('a division by a range touching zero at either end is unbounded; an unbounded divisor too', () => { + const division = { _warnedAboutDivideByZero: false }; + + expect(nodeBound(binary(value(4), value(0, timing(0, 1)), division)).range).toBeUndefined(); + expect(nodeBound(binary(value(4), value(-1, timing(-1, 0)), division)).range).toBeUndefined(); + expect(nodeBound(binary(value(4), value(4, null, 0, false), division)).range).toBeUndefined(); + }); + + it('a binary node with an unbounded operand keeps both operands\' roots', () => { + const a = value(1, null, 0, false); + const b = value(2); + + expect(nodeBound(binary(a, b))).toEqual({ range: undefined, running: false, roots: [a, b] }); + }); +}); + +const TX0 = [{ translateX: 0 }]; +const TY0 = [{ translateY: 0 }]; + +describe('transform geometry', () => { + /** The view (rect SELF) under an Animated host at `host`, with `extra` style on both. */ + function scaled( + transform: unknown[], + rendered: unknown[], + host: WindowRect, + extra: Record = {}, + ): WindowRect | undefined { + return moved(under({ transform, ...extra }, { transform: rendered, ...extra }, host)); + } + + it('scaleX and scaleY each scale their own axis, about a host that is not square', () => { + // Host 0,0 400x800: centre 200,400. + expect(scaled([{ scaleX: value(2) }], [{ scaleX: 1 }], rect(0, 0, 400, 800))).toEqual(rect(-180, 100, 400, 50)); + expect(scaled([{ scaleY: value(2) }], [{ scaleY: 1 }], rect(0, 0, 400, 800))).toEqual(rect(10, -200, 200, 350)); + }); + + it('a scale inside a translation scales the translation too', () => { + // Rendered: scale 2 after translateY 0; now translateY 10 inside: drawn 2 x 10 lower. + expect( + scaled([{ scale: 2 }, { translateY: value(10) }], [{ scale: 2 }, { translateY: 0 }], rect(0, 0, 400, 800)), + ).toEqual(rect(10, 100, 200, 70)); + }); + + it('an origin set in lengths, with a translation, pivots where the host is', () => { + // Origin 10px,20px of a host laid out at 100,100 (drawn 50 to the right by translateX): pivot 110,120. + const at = scaled( + [{ translateX: 50 }, { scale: value(2) }], + [{ translateX: 50 }, { scale: 1 }], + rect(150, 100, 200, 200), + { transformOrigin: '10px 20px' }, + ); + + expect(at).toEqual(rect(-140, 80, 400, 100)); + }); + + it('a rendered value missing for one step goes back from the current values alone', () => { + expect( + scaled([{ translateX: value(30) }, { translateY: value(5) }], [{ translateX: 10 }], rect(0, 0, 400, 800)), + ).toEqual(rect(10, 100, 200, 50)); + }); + + it.each(['rotate', 'rotateX', 'rotateY', 'rotateZ', 'skewX', 'skewY'])('%s of 0deg does nothing', (name) => { + expect( + moved(under({ transform: [{ translateX: value(5) }, { [name]: '0deg' }] }, { transform: [{ translateX: 0 }] })), + ).toEqual(rect(10, 100, 205, 50)); + }); + + it('an Animated component whose host is right below it, at the bottom of the path', () => { + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateX: value(5) }] } }, + children: [fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: TX0 } }, [self()])], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + + expect(moved(ancestry)).toEqual(rect(10, 100, 205, 50)); + }); + + it('an Animated component with a composite between it and its host finds the host', () => { + expect( + moved( + under({ transform: [{ scale: value(2) }] }, { transform: [{ scale: 1 }] }, rect(0, 0, 400, 400), (spec) => + composite([spec]), + ), + ), + ).toEqual(rect(-180, 0, 400, 150)); + }); + + it('an origin from the host the Animated component renders, not one above it', () => { + const fiber = selfIn( + root([ + fabricHost('RCTView', rect(0, 0, 400, 800), {}, [ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ scale: value(2) }] } }, + children: [fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: [{ scale: 1 }] } }, [self()])], + }, + ]), + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + + expect(moved(ancestry)).toEqual(rect(-180, 0, 400, 150)); + }); + + it('a translation inside a scale goes back with the scaled translation: the view where it is', () => { + // Rendered and now the same: nothing moves. + expect( + scaled([{ scale: 2 }, { translateY: value(10) }], [{ scale: 2 }, { translateY: 10 }], rect(0, 0, 400, 800)), + ).toEqual(SELF); + }); + + it('an Animated component with no props moves nothing', () => { + const ancestry = under(undefined); + (ancestry.path[ancestry.animated[0] as number] as FiberLike).memoizedProps = null; + ancestry.resolved = undefined; + + expect(nativeMotion(ancestry)).toBeNull(); + }); + + it('an object in a transform that is not a node is not native', () => { + expect(nativeMotion(under({ transform: [{ translateX: { value: 1 } }] }))).toBeNull(); + }); + + it('a level that cannot be read reports nothing running and nothing to watch', () => { + const throwing = value(1); + Object.defineProperty(throwing, '_animation', { + get(): never { + throw new Error('torn'); + }, + }); + const motion = nativeMotion(under({ transform: [{ translateX: throwing }] }, { transform: [{ translateX: 0 }] })); + + expect(motion).toEqual(expect.objectContaining({ running: false, roots: [] })); + }); + + /** The ancestry of a view under a Reanimated component styled `style`. */ + function underReanimated(style: unknown): ReturnType { + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: Object.assign(() => null, { displayName: 'AnimatedComponent(View)' }), + memoizedProps: { style }, + children: [view([self()])], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + return ancestry; + } + + it.each<[string, unknown]>([ + ['a CSS transition (Reanimated 4)', { opacity: 1, transitionProperty: 'transform', transitionDuration: 300 }], + ['a CSS animation (Reanimated 4)', [{ animationName: { from: {}, to: {} } }]], + ['a shared value set inline', { opacity: { value: 1, _isReanimatedSharedValue: true } }], + ['a shared value (by its modify) set inline', { opacity: { value: 1, modify: () => undefined } }], + ['a shared value in its transform', { transform: [{ translateX: { value: 0, modify: () => undefined } }] }], + ])('P3-d: %s moves the view: the whole surface', (_, style) => { + const motion = nativeMotion(underReanimated(style)); + + expect(motion?.levels).toHaveLength(1); + expect(motion?.levels[0]?.move(SELF)).toBeUndefined(); + }); + + it.each<[string, unknown]>([ + ['plain values', { opacity: 1, backgroundColor: null, transform: [{ translateX: 3 }, 'x', null] }], + ['a transform that is not a list', { transform: 'none', modify: 1 }], + ['an object that is not a shared value', { shadowOffset: { width: 1, height: 1 } }], + ['a function with a modify', { opacity: Object.assign(() => 1, { modify: () => undefined }) }], + ['a value marked shared with false', { opacity: { value: 1, _isReanimatedSharedValue: false, modify: 1 } }], + ])('P3-d: a Reanimated component with %s moves nothing', (_, style) => { + expect(nativeMotion(underReanimated(style))).toBeNull(); + }); + + it('a Reanimated style list with a number (a registered style) in it moves nothing', () => { + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: Object.assign(() => null, { displayName: 'AnimatedComponent(View)' }), + memoizedProps: { style: [7, { viewDescriptors: {}, initial: { value: {} } }] }, + children: [view([self()])], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + + expect(nativeMotion(ancestry)).toBeNull(); + }); + + it('a Reanimated component with no props moves nothing', () => { + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: Object.assign(() => null, { displayName: 'AnimatedComponent(View)' }), + children: [view([self()])], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + (ancestry.path[ancestry.animated[0] as number] as FiberLike).memoizedProps = null; + isHidden(ancestry); + + expect(nativeMotion(ancestry)).toBeNull(); + }); +}); + +describe('throughMotion, clippers', () => { + it('a clipper above is applied after the move: a view moved into it is seen there', () => { + const fiber = selfIn( + root([ + scrollView(rect(0, 0, 400, 150), [ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: value(0, timing(0, -100)) }] } }, + children: [fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: TY0 } }, [self()])], + }, + ]), + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + const low = rect(10, 200, 200, 50); + + expect(throughMotion(low, ancestry, nativeMotion(ancestry)!).visible).toEqual(rect(10, 100, 200, 50)); + }); + + it('a clipper right above the view, inside the transform, is applied before the move', () => { + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: value(0, timing(0, 300)) }] } }, + children: [ + fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: [{ translateY: 0 }] } }, [ + scrollView(rect(0, 0, 400, 130), [self()]), + ]), + ], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + + expect(throughMotion(SELF, ancestry, nativeMotion(ancestry)!).visible).toEqual(rect(10, 100, 200, 330)); + }); + + it('cut away inside a nested transform, nothing is seen whatever the outer one does', () => { + const fiber = selfIn( + root([ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateY: value(0, timing(0, 300)) }] } }, + children: [ + fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: [{ translateY: 0 }] } }, [ + { + tag: FiberTag.FunctionComponent, + type: AnimatedView, + memoizedProps: { style: { transform: [{ translateX: value(0, timing(0, 5)) }] } }, + children: [ + fabricHost('RCTView', rect(0, 0, 400, 400), { style: { transform: [{ translateX: 0 }] } }, [ + scrollView(rect(0, 0, 400, 50), [self()]), + ]), + ], + }, + ]), + ], + }, + ]), + ); + const ancestry = ancestryOfFiber(fiber); + isHidden(ancestry); + + expect(throughMotion(SELF, ancestry, nativeMotion(ancestry)!).visible).toBeNull(); + }); +}); diff --git a/packages/react-native/src/secure/__tests__/tracker.test.ts b/packages/react-native/src/secure/__tests__/tracker.test.ts index 11175ca7..9ea57016 100644 --- a/packages/react-native/src/secure/__tests__/tracker.test.ts +++ b/packages/react-native/src/secure/__tests__/tracker.test.ts @@ -21,6 +21,7 @@ const mockState = { nodes: [] as unknown[], published: [] as unknown[][], cleared: 0, + ancestry: null as object | null, }; /** Registers `fn` in `list`, and records its removal under `name`. */ @@ -49,7 +50,8 @@ jest.mock('../measureLoop', () => ({ }, })); jest.mock('../ancestry', () => ({ - ancestryOfInstance: () => ({ readable: true }), + ancestryOfInstance: () => + mockState.ancestry ?? { self: null, path: [], hosts: [], screens: [], animated: [], readable: true }, isHidden: () => mockState.hidden, screensHide: () => mockState.screensHide, clipToAncestors: (rect: WindowRect) => (mockState.clipTo === null ? rect : mockState.clipTo(rect)), @@ -68,6 +70,8 @@ jest.mock('../registry', () => ({ }, })); +import { SECURE_SHRINK_HOLD_MS } from '../hold'; +import { SECURE_TRANSITION_HOLD_MS } from '../transition'; import { STEADY_FRAMES, SecureTracker, monotonicNow } from '../tracker'; const rect = (x: number, y: number, width: number, height: number): WindowRect => ({ x, y, width, height }); @@ -93,7 +97,15 @@ const view = { beforeEach(() => { jest.useFakeTimers(); Object.assign(mockLoop, { tick: [], frame: [], parked: [], watched: [], removed: [] }); - Object.assign(mockState, { hidden: false, screensHide: false, clipTo: null, nodes: [], published: [], cleared: 0 }); + Object.assign(mockState, { + hidden: false, + screensHide: false, + clipTo: null, + nodes: [], + published: [], + cleared: 0, + ancestry: null, + }); self = rect(0, 100, 100, 50); measures = 0; pending = null; @@ -475,6 +487,222 @@ describe('SecureTracker', () => { }); }); + describe('unmounted', () => { + const later = (ms: number): void => { + jest.advanceTimersByTime(ms); + }; + + it('holds what it covered on the tick, then removes it all and leaves the tick', () => { + tracker.start(); + mockState.published = []; + + tracker.depart(); + expect(last()).toEqual([self]); + expect(mockLoop.tick).toHaveLength(1); + expect(mockState.cleared).toBe(0); + + later(SECURE_SHRINK_HOLD_MS - 100); + runTick(); + expect(last()).toEqual([self]); + + later(300); + runTick(); + expect(mockState.cleared).toBe(1); + expect(mockLoop.tick).toHaveLength(0); + }); + + it('with nothing published, removes it at once', () => { + mockState.clipTo = () => null; + tracker.start(); + + tracker.depart(); + + expect(mockState.cleared).toBe(1); + expect(mockLoop.tick).toHaveLength(0); + }); + + it('not started, does nothing', () => { + tracker.depart(); + + expect(mockState.published).toEqual([]); + expect(mockState.cleared).toBe(0); + }); + + it('a remount ends what the unmount left, and starts afresh', () => { + tracker.start(); + tracker.depart(); + + tracker.start(); + + expect(mockLoop.tick).toHaveLength(1); + later(SECURE_SHRINK_HOLD_MS + 200); + runTick(); + expect(last()).toEqual([self]); + expect(mockState.cleared).toBe(0); + }); + + /** An ancestry whose `path` throws while `throwing()` says so. */ + function tornAncestry(throwing: () => boolean): object { + const ancestry = { self: null, hosts: [], screens: [], animated: [], readable: true }; + Object.defineProperty(ancestry, 'path', { + get(): unknown[] { + if (throwing()) { + throw new Error('torn'); + } + return []; + }, + }); + return ancestry; + } + + it('a tree that throws as it mounts still publishes the view, and warns once', () => { + let torn = true; + mockState.ancestry = tornAncestry(() => torn); + tracker.start(); + torn = false; + + expect(last()).toEqual([self]); + expect(warn).toHaveBeenCalledTimes(1); + tracker.depart(); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('a tree that throws as it unmounts still holds the view, and warns once', () => { + let torn = false; + mockState.ancestry = tornAncestry(() => torn); + tracker.start(); + torn = true; + + tracker.depart(); + + expect(last()).toEqual([self]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('nothing carrying it, or never measured: nothing to warn about', () => { + tracker.start(); + tracker.depart(); + const unmeasured = new SecureTracker(() => null, {}); + unmeasured.start(); + unmeasured.depart(); + + expect(warn).not.toHaveBeenCalled(); + }); + + it('a tree that throws while a transition carries it still publishes the view, and warns once', () => { + let torn = false; + const stack = { type: 'RNSScreenStack' }; + const path = [{ type: 'RNSScreen', alternate: null }, { ...stack, alternate: stack }]; + const ancestry = { self: null, hosts: [{ index: 0, above: 1, screen: true }], screens: [], animated: [] }; + Object.defineProperty(ancestry, 'path', { + get(): unknown[] { + if (torn) { + throw new Error('torn'); + } + return path; + }, + }); + mockState.ancestry = { ...ancestry, readable: true }; + Object.defineProperty(mockState.ancestry, 'path', Object.getOwnPropertyDescriptor(ancestry, 'path')!); + tracker.start(); + torn = true; + self = rect(0, 50, 100, 50); + + runFrames(); + + expect((last() as WindowRect[]).slice(1, 2)).toEqual([self]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('publishes what is left as it runs out: the cover outlasting the view\'s own hold', () => { + const selfFiber = { type: 'Self' }; + const screen = { type: 'RNSScreen', alternate: { type: 'RNSScreen' } }; + const stack = { type: 'RNSScreenStack', alternate: { type: 'RNSScreenStack' }, deletions: [screen] }; + mockState.ancestry = { + self: selfFiber, + path: [screen, stack], + hosts: [{ index: 0, above: 1, screen: true }], + screens: [], + animated: [], + readable: true, + }; + later(50); + tracker.start(); + + // Popped: the screen (unmeasurable here, so the whole surface) and the view. + tracker.depart(); + expect((last() as WindowRect[]).length).toBe(2); + // The view's own hold has run out (600 ms); the cover lasts until 850. + later(570); + runTick(); + expect((last() as WindowRect[]).length).toBe(1); + later(250); + runTick(); + expect(mockState.cleared).toBe(1); + }); + + it('a tree that throws only where a transition is looked for still publishes the view, and warns once', () => { + const ancestry = { self: null, path: [], screens: [], animated: [], readable: true }; + Object.defineProperty(ancestry, 'hosts', { + get(): never { + throw new Error('torn'); + }, + }); + mockState.ancestry = ancestry; + + tracker.start(); + + expect(last()).toEqual([self]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('a screen whose alternate cannot be read as it mounts still publishes the view, and warns once', () => { + const screen = { type: 'RNSScreen' }; + Object.defineProperty(screen, 'alternate', { + get(): never { + throw new Error('torn'); + }, + }); + mockState.ancestry = { + self: null, + path: [screen, { type: 'RNSScreenStack' }], + hosts: [{ index: 0, above: 1, screen: true }], + screens: [], + animated: [], + readable: true, + }; + + tracker.start(); + + expect(last()).toEqual([self]); + expect(warn).toHaveBeenCalledTimes(1); + }); + + it('a view carried in stays per-frame until the transition hold ends, then parks without the cover', () => { + const stack = { type: 'RNSScreenStack' }; + // A screen with no alternate: pushed by the commit that mounts the view. + mockState.ancestry = { + self: null, + path: [{ type: 'RNSScreen', alternate: null }, { ...stack, alternate: stack }], + hosts: [{ index: 0, above: 1, screen: true }], + screens: [], + animated: [], + readable: true, + }; + mockState.clipTo = cutAt120; + tracker.start(); + runFrames(STEADY_FRAMES + 2); + expect(mockLoop.parked).toHaveLength(0); + expect((last() as WindowRect[]).length).toBe(2); + + later(SECURE_TRANSITION_HOLD_MS + 100); + runFrames(STEADY_FRAMES + 2); + + expect(mockLoop.parked).toHaveLength(1); + expect(last()).toEqual([cutAt120(self)]); + }); + }); + it('wholly visible and still, a tick measures it; moving, it stays on the tick', () => { tracker.start(); self = rect(0, 50, 100, 50); diff --git a/packages/react-native/src/secure/__tests__/transition.ios.test.ts b/packages/react-native/src/secure/__tests__/transition.ios.test.ts new file mode 100644 index 00000000..dbf23ae5 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/transition.ios.test.ts @@ -0,0 +1,36 @@ +/** + * On iOS a react-native-screens modal presentation slides its screen up + * over the one below by default. + */ +import { stackMotionOf } from '../transition'; + +jest.mock('react-native', () => ({ Platform: { OS: 'ios' } })); + +it('a modal presentation with the default animation slides up on iOS; a push does not', () => { + expect(stackMotionOf({ stackPresentation: 'formSheet' })).toBe('upwards'); + expect(stackMotionOf({ stackAnimation: 'default', stackPresentation: 'modal' })).toBe('upwards'); + expect(stackMotionOf({ stackPresentation: 'push' })).toBe('any'); + // An animation of the app's own is not the default slide. + expect(stackMotionOf({ stackAnimation: 'custom', stackPresentation: 'modal' })).toBe('any'); +}); + +const PRESENTATIONS = [ + 'modal', + 'fullScreenModal', + 'formSheet', + 'pageSheet', + 'transparentModal', + 'containedModal', + 'containedTransparentModal', +]; + +it.each(PRESENTATIONS)('%s slides up on iOS, and scales the screen below: any motion there', (stackPresentation) => { + expect(stackMotionOf({ stackPresentation })).toBe('upwards'); + expect(stackMotionOf({ stackPresentation }, false)).toBe('any'); +}); + +it('an explicit slide from the bottom leaves the screen below where it is', () => { + expect(stackMotionOf({ stackAnimation: 'slide_from_bottom', stackPresentation: 'formSheet' }, false)).toBe( + 'upwards', + ); +}); diff --git a/packages/react-native/src/secure/__tests__/transition.test.ts b/packages/react-native/src/secure/__tests__/transition.test.ts new file mode 100644 index 00000000..06d6efe0 --- /dev/null +++ b/packages/react-native/src/secure/__tests__/transition.test.ts @@ -0,0 +1,528 @@ +/** + * `transition.ts` tells a native transition (a stack push or pop, a Modal + * opening or closing) from a view coming and going in place, by the fiber + * tree alone, and says where the transition can draw the view. + */ +import { MODAL_SCREEN_HOST_TYPE, SCREEN_HOST_TYPE, ancestryOfFiber, noAncestry } from '../ancestry'; +import { + SECURE_TRANSITION_HOLD_MS, + WHOLE_SURFACE, + carriedCover, + carrierEntering, + carrierLeaving, + bounding, + carriedCovers, + carriersOf, + deletionRoot, + joinStack, + keyOf, + noteBirth, + observeStack, + screenCover, + screensOn, + stackMotionOf, + stackMoving, +} from '../transition'; +import type { StackMotion } from '../transition'; +import type { FiberLike, WindowRect } from '../../viewtree/fiber'; +import { composite, fabricHost, fakeRenderer, rect, root, self, selfIn, view } from './fakeAncestors'; +import { buildFiberTree } from '../../viewtree/__tests__/fakeFibers'; +import type { FiberSpec } from '../../viewtree/__tests__/fakeFibers'; +import type { FakeRenderer } from './fakeAncestors'; + +let renderer: FakeRenderer; +jest.mock('react-native/Libraries/ReactNative/RendererProxy', () => ({ + getPublicInstanceFromInternalInstanceHandle: (fiber: FiberLike) => + renderer.getPublicInstanceFromInternalInstanceHandle(fiber), +})); + +beforeEach(() => { + renderer = fakeRenderer(); +}); + +const SCREEN = rect(0, 0, 400, 800); +const SELF = rect(10, 100, 200, 50); + +const onScreen = (container: string, screen = SCREEN_HOST_TYPE): FiberLike => + selfIn(root([fabricHost(container, null, {}, [fabricHost(screen, SCREEN, {}, [view([self()])])])])); + +const inModal = (props: unknown): FiberLike => + selfIn( + root([ + fabricHost('RCTModalHostView', null, props as Record, [ + fabricHost('RCTView', rect(0, 0, 400, 800), {}, [fabricHost('RNSScreenContainer', null, {}, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, {}, [self()]), + ])]), + ]), + ]), + ); + +const indexOf = (fiber: FiberLike, type: string): number => + ancestryOfFiber(fiber).path.findIndex((ancestor) => ancestor.type === type); + +describe('carriersOf', () => { + it('a screen in a stack carries the view', () => { + const fiber = onScreen('RNSScreenStack'); + + expect(carriersOf(ancestryOfFiber(fiber))).toEqual([ + { kind: 'screen', index: indexOf(fiber, SCREEN_HOST_TYPE), stack: indexOf(fiber, 'RNSScreenStack') }, + ]); + }); + + it('so does a modal screen in a stack', () => { + const fiber = onScreen('RNSScreenStack', MODAL_SCREEN_HOST_TYPE); + + expect(carriersOf(ancestryOfFiber(fiber)).map((carrier) => carrier.kind)).toEqual(['screen']); + }); + + it('every stack screen, nearest first: in nested stacks the one pushed or popped moves', () => { + const inner = fabricHost('RNSScreenStack', null, {}, [fabricHost(SCREEN_HOST_TYPE, SCREEN, {}, [self()])]); + const fiber = selfIn( + root([fabricHost('RNSScreenStack', null, {}, [fabricHost(SCREEN_HOST_TYPE, SCREEN, {}, [inner])])]), + ); + + expect(carriersOf(ancestryOfFiber(fiber))).toEqual([ + { kind: 'screen', index: 0, stack: 1 }, + { kind: 'screen', index: 2, stack: 3 }, + ]); + }); + + it('a screen in a tab container does not; nothing else does either', () => { + expect(carriersOf(ancestryOfFiber(onScreen('RNSScreenContainer')))).toEqual([]); + }); + + it('a Modal carries a view on a screen that does not', () => { + const fiber = inModal({}); + + expect(carriersOf(ancestryOfFiber(fiber))).toEqual([{ kind: 'modal', index: indexOf(fiber, 'RCTModalHostView') }]); + }); + + it('a stack screen inside a Modal and the Modal both carry it', () => { + const fiber = selfIn( + root([ + fabricHost('RCTModalHostView', null, {}, [ + fabricHost('RNSScreenStack', null, {}, [fabricHost(SCREEN_HOST_TYPE, SCREEN, {}, [self()])]), + ]), + ]), + ); + + expect(carriersOf(ancestryOfFiber(fiber))).toEqual([ + { kind: 'screen', index: 0, stack: 1 }, + { kind: 'modal', index: 2 }, + ]); + }); + + it('a screen with no host above it carries nothing, without throwing', () => { + const fiber = selfIn(root([fabricHost(SCREEN_HOST_TYPE, SCREEN, {}, [self()])])); + + expect(carriersOf(ancestryOfFiber(fiber))).toEqual([]); + }); + + it('a Modal right above the view carries it', () => { + const fiber = selfIn(root([fabricHost('RCTModalHostView', null, {}, [self()])])); + + expect(carriersOf(ancestryOfFiber(fiber))).toEqual([{ kind: 'modal', index: 0 }]); + }); + + it('nothing above the view: nothing carries it', () => { + expect(carriersOf(noAncestry())).toEqual([]); + }); +}); + +describe('carriedCovers and bounding', () => { + it('joins what each carrier covers, and is null when none covers anything', () => { + const fiber = selfIn( + root([ + fabricHost('RCTModalHostView', null, { animationType: 'slide' }, [ + fabricHost('RNSScreenStack', null, {}, [fabricHost(SCREEN_HOST_TYPE, rect(0, 50, 400, 300), {}, [self()])]), + ]), + ]), + ); + const ancestry = ancestryOfFiber(fiber); + const carriers = carriersOf(ancestry); + + const joined = { x: 0, y: 50, width: 400, height: WHOLE_SURFACE.height - 50 }; + expect(carriedCovers(ancestry, carriers, SELF)).toEqual(joined); + expect(carriedCovers(ancestry, [carriers[1] as never], null)).toBeNull(); + expect(carriedCovers(ancestry, [], SELF)).toBeNull(); + }); + + it('the bounding box of two rectangles, either way round', () => { + const a = rect(10, 20, 30, 40); + const b = rect(0, 30, 100, 5); + + expect(bounding(a, b)).toEqual(rect(0, 20, 100, 40)); + expect(bounding(b, a)).toEqual(rect(0, 20, 100, 40)); + expect(bounding(a, null)).toBe(a); + expect(bounding(a, undefined)).toBe(a); + }); +}); + +describe('carrierEntering', () => { + it('a carrier mounted whole, as an app starts, is not carried in, then or later', () => { + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + const carrier = { kind: 'screen' as const, index: 0 }; + + // Nothing above it was there. + expect(carrierEntering(ancestry, carrier, 1000)).toBeUndefined(); + for (const fiber of ancestry.path) { + fiber.alternate = { ...fiber, alternate: fiber }; + } + expect(carrierEntering(ancestry, carrier, 1001)).toBeUndefined(); + }); + + it('a carrier created by the commit running now is carried in until the hold ends, and stays so', () => { + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + const carrier = { kind: 'screen' as const, index: 0 }; + const above = ancestry.path[1] as FiberLike; + above.alternate = { ...above, alternate: above }; + + expect(carrierEntering(ancestry, carrier, 1000)).toBe(1000 + SECURE_TRANSITION_HOLD_MS); + // Rendered again since, a later view still finds it carried in, from when it was created. + const screen = ancestry.path[0] as FiberLike; + screen.alternate = { ...screen, alternate: screen }; + expect(carrierEntering(ancestry, carrier, 1300)).toBe(1000 + SECURE_TRANSITION_HOLD_MS); + expect(carrierEntering(ancestry, carrier, 1000 + SECURE_TRANSITION_HOLD_MS - 1)).toBe( + 1000 + SECURE_TRANSITION_HOLD_MS, + ); + expect(carrierEntering(ancestry, carrier, 1000 + SECURE_TRANSITION_HOLD_MS)).toBeUndefined(); + }); + + it('N3: a carrier there before, first seen now, is taken as coming now: once', () => { + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + for (const fiber of ancestry.path) { + fiber.alternate = { ...fiber, alternate: fiber }; + } + const carrier = { kind: 'screen' as const, index: 0 }; + + // A screen pushed with a spinner, its secure content mounting a commit later. + expect(carrierEntering(ancestry, carrier, 5)).toBe(5 + SECURE_TRANSITION_HOLD_MS); + expect(carrierEntering(ancestry, carrier, 300)).toBe(5 + SECURE_TRANSITION_HOLD_MS); + expect(carrierEntering(ancestry, carrier, 5000)).toBeUndefined(); + }); + + it('N3: a carrier rendered before, with nothing above rendered before, is first seen now too', () => { + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + const screen = ancestry.path[0] as FiberLike; + screen.alternate = { ...screen, alternate: screen }; + + expect(carrierEntering(ancestry, { kind: 'screen', index: 0 }, 7)).toBe(7 + SECURE_TRANSITION_HOLD_MS); + }); + + it('N3: a screen seen pushed is carried in from then; a birth known already stands', () => { + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + for (const fiber of ancestry.path) { + fiber.alternate = { ...fiber, alternate: fiber }; + } + const carrier = { kind: 'screen' as const, index: 0 }; + const key = keyOf(ancestry.path[0] as FiberLike); + + noteBirth(key, 100); + noteBirth(key, 200); + expect(carrierEntering(ancestry, carrier, 300)).toBe(100 + SECURE_TRANSITION_HOLD_MS); + }); +}); + +describe('deletionRoot and carrierLeaving', () => { + const deleting = (parent: FiberLike, child: unknown): void => { + (parent as FiberLike & { deletions?: unknown }).deletions = child; + }; + + it('finds nothing when nothing on the way up is deleted', () => { + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + deleting(ancestry.path[1] as FiberLike, 'not a list'); + + expect(deletionRoot(ancestry)).toBeUndefined(); + expect(carrierLeaving(ancestry, { kind: 'screen', index: 0 })).toBe(false); + }); + + it('the view itself deleted is -1, below any carrier', () => { + const fiber = onScreen('RNSScreenStack'); + const ancestry = ancestryOfFiber(fiber); + deleting(ancestry.path[0] as FiberLike, [fiber]); + + expect(deletionRoot(ancestry)).toBe(-1); + expect(carrierLeaving(ancestry, { kind: 'screen', index: 1 })).toBe(false); + }); + + it('finds the other fiber of a deleted pair, listed on the other fiber of its parent', () => { + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + const screen = ancestry.path[1] as FiberLike; + const stack = ancestry.path[2] as FiberLike; + screen.alternate = { ...screen, alternate: screen }; + stack.alternate = { ...stack, alternate: stack }; + deleting(stack.alternate, [screen.alternate]); + + expect(deletionRoot(ancestry)).toBe(1); + expect(carrierLeaving(ancestry, { kind: 'screen', index: 1 })).toBe(true); + expect(carrierLeaving(ancestry, { kind: 'screen', index: 2 })).toBe(false); + }); + + it('a deletions list left from an earlier commit, naming other fibers, deletes nothing here', () => { + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + deleting(ancestry.path[2] as FiberLike, [{ ...(ancestry.path[1] as FiberLike) }]); + + expect(deletionRoot(ancestry)).toBeUndefined(); + }); + + it('a view with no fiber has nothing deleted', () => { + expect(deletionRoot(noAncestry())).toBeUndefined(); + }); +}); + +describe('carriedCover', () => { + it('a screen covers its own rectangle', () => { + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + + expect(carriedCover(ancestry, { kind: 'screen', index: 1 }, SELF)).toEqual(SCREEN); + }); + + it('a screen that cannot be measured, or throws, covers the whole surface', () => { + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + ((ancestry.path[1] as FiberLike).stateNode as { rect: unknown }).rect = null; + expect(carriedCover(ancestry, { kind: 'screen', index: 1 }, SELF)).toEqual(WHOLE_SURFACE); + + renderer.getPublicInstanceFromInternalInstanceHandle = () => { + throw new Error('gone'); + }; + const fresh = ancestryOfFiber(onScreen('RNSScreenStack')); + expect(carriedCover(fresh, { kind: 'screen', index: 1 }, SELF)).toEqual(WHOLE_SURFACE); + }); + + it('a Modal view never measured covers nothing more', () => { + const fiber = inModal({ animationType: 'slide' }); + const carrier = { kind: 'modal' as const, index: indexOf(fiber, 'RCTModalHostView') }; + + expect(carriedCover(ancestryOfFiber(fiber), carrier, null)).toBeNull(); + }); + + it.each([ + ['slides', { animationType: 'slide' }, { ...SELF, height: WHOLE_SURFACE.height - SELF.y }], + ['fades', { animationType: 'fade' }, null], + ['has no animation', { animationType: 'none' }, null], + ['has no props', null, null], + ])('a Modal that %s', (_, props, expected) => { + const fiber = inModal(props); + const ancestry = ancestryOfFiber(fiber); + (ancestry.path[indexOf(fiber, 'RCTModalHostView')] as FiberLike).memoizedProps = props; + + const carrier = { kind: 'modal' as const, index: indexOf(fiber, 'RCTModalHostView') }; + expect(carriedCover(ancestry, carrier, SELF)).toEqual(expected); + }); +}); + +describe('stack motion and covers', () => { + it.each<[unknown, unknown, StackMotion]>([ + ['slide_from_right', undefined, 'sideways'], + ['slide_from_left', undefined, 'sideways'], + ['ios_from_right', undefined, 'sideways'], + ['ios_from_left', undefined, 'sideways'], + ['slide_from_bottom', undefined, 'upwards'], + ['fade', undefined, 'still'], + ['simple_push', 'push', 'sideways'], + ['fade_from_bottom', undefined, 'upwards'], + ['none', undefined, 'still'], + ['default', 'modal', 'any'], + [undefined, undefined, 'any'], + ['flip', undefined, 'any'], + ])('%s, presented %s: %s', (stackAnimation, stackPresentation, expected) => { + expect(stackMotionOf({ stackAnimation, stackPresentation })).toBe(expected); + }); + + it('props that are not there: any motion', () => { + expect(stackMotionOf(null)).toBe('any'); + }); + + it.each<[StackMotion, boolean, WindowRect]>([ + ['sideways', true, rect(0, 100, 400, 50)], + ['sideways', false, rect(0, 100, 400, 50)], + ['upwards', true, rect(10, 100, 200, 700)], + ['upwards', false, SELF], + ['still', true, SELF], + ['any', false, SCREEN], + ])('%s, the screen moving %s', (motion, moving, expected) => { + expect(screenCover(motion, SCREEN, SELF, moving)).toEqual(expected); + }); + + it('a view below the screen\'s bottom still has a column of its own height', () => { + expect(screenCover('upwards', rect(0, 0, 400, 100), SELF, true)).toEqual(SELF); + }); + + it('a view not on screen is covered nowhere, whatever the motion', () => { + expect(screenCover('any', SCREEN, null, true)).toBeNull(); + const ancestry = ancestryOfFiber(onScreen('RNSScreenStack')); + expect(carriedCover(ancestry, { kind: 'screen', index: 1 }, null)).toBeNull(); + expect(carriedCover(ancestry, { kind: 'screen', index: 1 }, undefined)).toEqual(SCREEN); + }); + + it('finds a stack\'s screens below wrapper components, not below hosts or inside screens', () => { + const screen = (name: string): FiberSpec => fabricHost(SCREEN_HOST_TYPE, SCREEN, { name }, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { name: 'inside' }, []), + ]); + const stack = buildFiberTree( + fabricHost('RNSScreenStack', null, {}, [ + screen('a'), + composite([screen('b')]), + fabricHost('RCTView', null, {}, [screen('c')]), + ]), + ); + + const names = [...screensOn(stack).values()].map((props) => (props as { name: string }).name); + expect(names).toEqual(['a', 'b', 'c']); + }); + + it('looks for screens 16 wrappers deep, and no deeper', () => { + const wrapped = (depth: number, name: string): FiberSpec => { + let spec: FiberSpec = fabricHost(SCREEN_HOST_TYPE, SCREEN, { name }, []); + for (let level = 0; level < depth; level += 1) { + spec = composite([spec]); + } + return spec; + }; + const stack = buildFiberTree(fabricHost('RNSScreenStack', null, {}, [wrapped(16, 'deep'), wrapped(17, 'deeper')])); + + expect([...screensOn(stack).values()].map((props) => (props as { name: string }).name)).toEqual(['deep']); + }); + + it('tells every view joined on a stack, until it leaves; a stack nobody joined tells nobody', () => { + const stack = {}; + const told = jest.fn(); + const leave = joinStack(stack, told); + const change = { motion: 'sideways' as const, until: 5, moving: {}, below: undefined }; + stackMoving(stack, change, true); + leave(); + stackMoving(stack, { ...change }, true); + stackMoving({}, { ...change, motion: 'any' }, true); + + expect(told).toHaveBeenCalledTimes(1); + expect(told).toHaveBeenCalledWith(change); + }); + + describe('R3-3: what a stack keeps of its recent changes', () => { + const screen = (name: string, props: Record = {}): FiberSpec => + fabricHost(SCREEN_HOST_TYPE, SCREEN, { name, ...props }, []); + /** A committed stack of `screens`; `before`: its render before, as its other fiber. */ + function stackOf(screens: FiberSpec[], before?: FiberSpec[]): FiberLike { + const stack = buildFiberTree(fabricHost('RNSScreenStack', null, {}, screens)); + if (before !== undefined) { + stack.alternate = { ...buildFiberTree(fabricHost('RNSScreenStack', null, {}, before)), alternate: stack }; + // The same hosts in both renders: the same keys. + let from = stack.alternate.child; + for (let to = stack.child; to !== null && from !== null; to = to.sibling, from = from.sibling) { + from.stateNode = to.stateNode; + } + } + return stack; + } + const keys = (stack: FiberLike): object[] => [...screensOn(stack).keys()]; + + it('read first, is compared with its render before: a pop no view saw is recorded from now', () => { + const stack = stackOf([screen('a')], [screen('a'), screen('b', { stackAnimation: 'slide_from_left' })]); + const [, bKey] = keys(stack.alternate as FiberLike); + + const changes = observeStack(stack, screensOn(stack), 1000); + + expect(changes).toEqual([ + { motion: 'sideways', until: 1000 + SECURE_TRANSITION_HOLD_MS, moving: bKey, below: keys(stack)[0] }, + ]); + // Read again: nothing new, the change kept within its hold, and dropped once it is over. + expect(observeStack(stack, screensOn(stack), 1500)).toEqual(changes); + expect(observeStack(stack, screensOn(stack), 1000 + SECURE_TRANSITION_HOLD_MS)).toEqual([]); + }); + + it('read first with no render before (mounted whole), records nothing', () => { + const stack = stackOf([screen('a'), screen('b')]); + + expect(observeStack(stack, screensOn(stack), 0)).toEqual([]); + }); + + it('a push since the last read: the screen it covers is the top before; its birth is noted', () => { + const stack = stackOf([screen('a')]); + const [aKey] = keys(stack); + observeStack(stack, screensOn(stack), 0); + (stack.child as FiberLike).sibling = { ...buildFiberTree(screen('c')), return: stack }; + const [, cKey] = keys(stack); + + expect(observeStack(stack, screensOn(stack), 100)).toEqual([ + { motion: 'any', until: 100 + SECURE_TRANSITION_HOLD_MS, moving: cKey, below: aKey }, + ]); + // Born at the push. + const anyFiber = { stateNode: { canonical: cKey } } as never; + expect(carrierEntering({ path: [anyFiber] } as never, { kind: 'screen', index: 0 }, 300)).toBe( + 100 + SECURE_TRANSITION_HOLD_MS, + ); + }); + + it('a change recorded by the moving screen\'s view is not recorded again; its screens are updated', () => { + const stack = stackOf([screen('a'), screen('b')]); + const [aKey, bKey] = keys(stack); + const key = keyOf(stack); + observeStack(stack, screensOn(stack), 0); + const popped = { motion: 'any' as const, until: 900, moving: bKey as object, below: undefined }; + stackMoving(key, popped, true); + (stack.child as FiberLike).sibling = null; + + // B's own view saw it leave: the screens read now match, and its change is the one kept. + expect(observeStack(stack, screensOn(stack), 200)).toEqual([popped]); + const pushed = { motion: 'still' as const, until: 1100, moving: bKey as object, below: undefined }; + stackMoving(key, pushed, false); + (stack.child as FiberLike).sibling = { ...buildFiberTree(screen('b')), stateNode: { canonical: bKey } }; + expect(observeStack(stack, screensOn(stack), 300)).toEqual([popped, pushed]); + expect(keys(stack)).toEqual([aKey, bKey]); + }); + + it('a screen told leaving is not found popped again once its hold is over', () => { + const stack = stackOf([screen('a'), screen('b')]); + const [, bKey] = keys(stack); + observeStack(stack, screensOn(stack), 0); + stackMoving(keyOf(stack), { motion: 'any', until: 800, moving: bKey as object, below: undefined }, true); + (stack.child as FiberLike).sibling = null; + + expect(observeStack(stack, screensOn(stack), 900)).toEqual([]); + }); + + it('a pop told before any view read the stack is not recorded again from its render before', () => { + const stack = stackOf([screen('a')], [screen('a'), screen('b')]); + const [, bKey] = keys(stack.alternate as FiberLike); + const change = { motion: 'any' as const, until: 800, moving: bKey as object, below: undefined }; + stackMoving(keyOf(stack), change, true); + + expect(observeStack(stack, screensOn(stack), 100)).toEqual([change]); + }); + + it('a change told before any view read the stack is kept for the first that does', () => { + const stack = stackOf([screen('a')]); + const change = { motion: 'any' as const, until: 800, moving: {}, below: undefined }; + stackMoving(keyOf(stack), change, true); + + expect(observeStack(stack, screensOn(stack), 100)).toEqual([change]); + expect(observeStack(stack, screensOn(stack), 800)).toEqual([]); + }); + }); + + it('keys a host by its shared canonical, on Paper by its shared instance, or else the fiber itself', () => { + const canonical = {}; + + expect(keyOf({ stateNode: { canonical } } as never)).toBe(canonical); + const bare = { stateNode: null } as never; + expect(keyOf(bare)).toBe(bare); + const notAnObject = { stateNode: 7 } as never; + expect(keyOf(notAnObject)).toBe(notAnObject); + // P3-e: Paper's host instance is shared by both fibers of the pair. + const instance = { canonical: null }; + expect(keyOf({ stateNode: instance } as never)).toBe(instance); + expect(keyOf({ stateNode: { canonical: 'x' } } as never)).toEqual({ canonical: 'x' }); + }); + + it('a screen moving sideways, its view never measured, covers the whole screen', () => { + const fiber = selfIn( + root([ + fabricHost('RNSScreenStack', null, {}, [ + fabricHost(SCREEN_HOST_TYPE, SCREEN, { stackAnimation: 'slide_from_right' }, [view([self()])]), + ]), + ]), + ); + + expect(carriedCover(ancestryOfFiber(fiber), { kind: 'screen', index: 1 }, undefined)).toEqual(SCREEN); + }); +}); diff --git a/packages/react-native/src/secure/ancestry.ts b/packages/react-native/src/secure/ancestry.ts index 1f18205f..d5245b57 100644 --- a/packages/react-native/src/secure/ancestry.ts +++ b/packages/react-native/src/secure/ancestry.ts @@ -118,6 +118,8 @@ interface HostAncestor { } export interface Ancestry { + /** The view's own host fiber, as stored at mount; `null` when there is none. */ + readonly self: FiberLike | null; /** 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. */ @@ -126,13 +128,57 @@ export interface Ancestry { readonly hosts: readonly HostAncestor[]; /** The screens among `hosts`. */ readonly screens: readonly HostAncestor[]; + /** + * Where in `path` the `Animated` components below the nearest root-kind + * host are (`Animated.View` and the like, nearest first): only their props + * can hold a native-driven transform, which the shadow tree never sees. + */ + readonly animated: readonly number[]; /** Whether the last {@link isHidden} read everything; `false` clips nothing. */ readable: boolean; + /** The current fibers of `path` as the last {@link isHidden} resolved them, if it did. */ + resolved: readonly FiberLike[] | undefined; } /** 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 }; + return { + self: null, + path: [], + root: null, + hosts: [], + screens: [], + animated: [], + readable: false, + resolved: undefined, + }; +} + +function displayNameOf(fiber: FiberLike): unknown { + const type = fiber.type as { displayName?: unknown; render?: { displayName?: unknown } } | null | undefined; + if (type === null || (typeof type !== 'function' && typeof type !== 'object')) { + return undefined; + } + return type.displayName ?? type.render?.displayName; +} + +/** + * Whether `fiber` is a Reanimated component (`createAnimatedComponent` of + * react-native-reanimated names every one `AnimatedComponent()`). + */ +export function isReanimatedComponent(fiber: FiberLike): boolean { + const name = displayNameOf(fiber); + return typeof name === 'string' && name.startsWith('AnimatedComponent('); +} + +/** + * Whether `fiber` is an `Animated` component (`Animated.createAnimatedComponent` + * names every one `Animated()`), as a plain function or a `forwardRef`, + * or a Reanimated one. + */ +export function isAnimatedComponent(fiber: FiberLike): boolean { + const name = displayNameOf(fiber); + return (typeof name === 'string' && name.startsWith('Animated(')) || isReanimatedComponent(fiber); } /** @@ -143,6 +189,7 @@ export function noAncestry(): Ancestry { export function ancestryOfFiber(fiber: FiberLike): Ancestry { const path: FiberLike[] = []; const hosts: HostAncestor[] = []; + const animated: number[] = []; let bounded = false; let current = fiber.return; for (let steps = 0; current != null && steps < MAX_ANCESTORS; steps += 1) { @@ -166,6 +213,8 @@ export function ancestryOfFiber(fiber: FiberLike): Ancestry { }); } bounded ||= boundary; + } else if (!bounded && isAnimatedComponent(current)) { + animated.push(path.length); } path.push(current); current = current.return; @@ -176,7 +225,16 @@ export function ancestryOfFiber(fiber: FiberLike): Ancestry { } 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 }; + return { + self: fiber, + path, + root, + hosts, + screens: hosts.filter((host) => host.screen), + animated, + readable: false, + resolved: undefined, + }; } /** @@ -186,8 +244,11 @@ export function ancestryOfFiber(fiber: FiberLike): Ancestry { */ export function ancestryOfInstance(instance: object): Ancestry { try { - // No fiber (or not an object) has no `.return`, or throws on reading it. + // No fiber (or not an object) has no `.return`; `null` throws on reading it. const fiber = (instance as { __internalInstanceHandle?: unknown }).__internalInstanceHandle; + if (typeof fiber !== 'object') { + return noAncestry(); + } return ancestryOfFiber(fiber as FiberLike); } catch { return noAncestry(); @@ -317,6 +378,7 @@ function screenHides(screen: HostAncestor, ancestry: Ancestry, resolved: readonl export function isHidden(ancestry: Ancestry): boolean { ancestry.readable = false; const resolved = resolve(ancestry); + ancestry.resolved = resolved; if (resolved === undefined) { return false; } @@ -363,12 +425,19 @@ export function screensHide(ancestry: Ancestry): boolean { /** {@link screensHide}'s answers in the running pass, by both fibers of the nearest screen. */ let passScreens: Map | undefined; +/** + * How many component fibers can stand between a ScrollView's host and its + * content container's host: on Android a vertical ScrollView renders its + * content through the `View` component (`VScrollContentViewNativeComponent`). + */ +export const CONTENT_DEPTH = 4; + /** * 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. + * it scrolls: the nearest host below it on the way to the view. */ export function clipWatchNodes(ancestry: Ancestry): FiberLike[] { if (!ancestry.readable) { @@ -378,11 +447,30 @@ export function clipWatchNodes(ancestry: Ancestry): FiberLike[] { .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]; + const below = ancestry.path + .slice(Math.max(0, host.index - CONTENT_DEPTH), host.index) + .reverse() + .find((fiber) => fiber.tag === FiberTag.HostComponent); + return host.scrolls && below !== undefined ? [below, clipper] : [clipper]; }); } +/** + * A ScrollView host's content container: the first host down its first + * children, through at most {@link CONTENT_DEPTH} components; `undefined` + * when there is none. + */ +export function contentOf(scroller: FiberLike): FiberLike | undefined { + let fiber = scroller.child; + for (let depth = 0; fiber != null && depth < CONTENT_DEPTH; depth += 1) { + if (fiber.tag === FiberTag.HostComponent) { + return fiber; + } + fiber = fiber.child; + } + return undefined; +} + /** Public instances of watched nodes, looked up once each. */ const nodeInstances = new WeakMap(); @@ -420,13 +508,32 @@ export function measurePass(pass: () => void): void { passMeasurements = new Map(); passChildren = new Map(); passScreens = new Map(); + passMemos = new Map(); try { pass(); } finally { passMeasurements = undefined; passChildren = undefined; passScreens = undefined; + passMemos = undefined; + } +} + +/** What {@link passMemo} computed in the running pass, by key. */ +let passMemos: Map | undefined; + +/** + * `compute()`, at most once per `key` in the running {@link measurePass} + * (many views under one `Animated` component read its nodes once a pass); + * every time outside a pass. + */ +export function passMemo(key: object, compute: () => T): T { + if (passMemos?.has(key)) { + return passMemos.get(key) as T; } + const value = compute(); + passMemos?.set(key, value); + return value; } /** @@ -459,7 +566,10 @@ function finite({ x, y, width, height }: WindowRect): boolean { /** * The part of `rect` inside every ancestor that clips now (as the last - * {@link isHidden} read them), or `null` when none of it is. + * {@link isHidden} read them), or `null` when none of it is. Only the + * ancestors between `path` indices `above` and `below` cut it: a native-driven + * transform between two clippers moves what the lower one left, and the upper + * one cuts where it was moved to. * * Fails closed: `rect` itself, unclipped, when it is not finite, the last * read was not complete, or any clipping ancestor throws, answers late or @@ -467,14 +577,21 @@ function finite({ x, y, width, height }: WindowRect): boolean { * nothing. Measures nothing, and returns `rect` as it is, when nothing * clips. */ -export function clipToAncestors(rect: WindowRect, ancestry: Ancestry): WindowRect | null { +export function clipToAncestors( + rect: WindowRect, + ancestry: Ancestry, + above = -1, + below = Number.POSITIVE_INFINITY, +): WindowRect | null { if (!finite(rect) || !ancestry.readable) { return rect; } const clips: Array = []; try { for (const host of ancestry.hosts) { - if (host.clips) { + // `above` and `below` are Animated components' indices, never a host's. + // Stryker disable next-line EqualityOperator -- so `>=` and `<=` pick the same hosts + if (host.clips && host.index > above && host.index < below) { clips.push(measureHost(host, ancestry.path[host.index] as FiberLike)); } } diff --git a/packages/react-native/src/secure/drivers.ts b/packages/react-native/src/secure/drivers.ts new file mode 100644 index 00000000..edd35a8b --- /dev/null +++ b/packages/react-native/src/secure/drivers.ts @@ -0,0 +1,342 @@ +/** + * What drives each native `Animated.Value`, as far as JS can know it. + * + * Natively, a value moves by an animation (which JS starts, and which hands + * JS its end value) or by a native event attached to it (a scroll, a + * gesture), whose values JS never sees. React Native attaches native events + * two ways: an `Animated` component attaches the `Animated.event`s in its + * props through `AnimatedEvent.__attach`, and `Animated.attachNativeEvent` + * attaches a mapping directly (React Native's own ScrollView does, for its + * sticky headers). Both are followed here, from when the package is first + * imported (`followEvents.ts`, imported for its effect by the package entry), + * and so is every value being made native (`AnimatedValue.__makeNative`). + * + * A native value is then one of: + * - driven by events: some are attached to it now (`eventsOn`), and it is + * wherever they take it; + * - known: made native while followed (or seen animating) and no event is + * attached now: only animations move it, and JS has where they leave it; + * - unknown: neither (made native before the package was imported, or its + * last event detached), or nothing can be followed (React Native moved + * these internals, or an attachment could not be read): anything may + * drive it. + * + * Patching is global and done once: each object patched carries this state + * under a symbol (not enumerable), so a second copy of this module (Fast + * Refresh, two copies of the package) shares it rather than wrapping again. + * An app that never renders a `` pays a few map updates per + * attach and detach, and nothing else. + */ + +/** A native event attached to a value: the view, the event's name, where in the event the value is. */ +export interface Attachment { + readonly view: unknown; + readonly event: unknown; + /** From the event's first argument: `['nativeEvent', 'contentOffset', 'y']`. */ + readonly path: readonly string[]; +} + +/** An attachment with the value it drives. */ +type Attached = Attachment & { readonly value: object }; + +interface DriverState { + /** The events attached to each value now (one entry per attachment). */ + readonly attached: WeakMap; + /** Per `AnimatedEvent`, the attachments of each `__attach` still in place, oldest first. */ + readonly events: WeakMap; + /** Values made native while followed, or seen animating, with no event since: only animations move them. */ + readonly known: WeakSet; + /** Called the moment JS moves a value, or makes it native, by value. */ + readonly onMove: WeakMap void>>; + /** An attachment could not be read: which values it drives is not known. */ + lost: boolean; +} + +/** Where the state is kept on every object patched, and on every function that wraps one. */ +// Stryker disable next-line StringLiteral -- any key nothing else uses +const KEY = Symbol.for('@bugsee/react-native/secure-drivers'); + +/** The state, while everything is followed; `null`: nothing can be trusted. */ +let drivers: DriverState | null = null; + +/** How deep in an event's mapping its values are looked for (`{ nativeEvent: { contentOffset: { y } } }`). */ +const MAPPING_DEPTH = 8; + +/** Anything with React Native's `Animated` node interface. */ +export function isNode(value: unknown): value is { __getValue: () => unknown; [field: string]: unknown } { + return typeof (value as { __getValue?: unknown } | null | undefined)?.__getValue === 'function'; +} + +/** + * The `Animated.Value`s in an event's mapping, with where each is (an + * `Animated.ValueXY`'s two at `x` and `y`), as React Native maps them. + */ +function attachmentsIn(mapping: unknown, view: unknown, event: unknown): Attached[] { + const found: Attached[] = []; + const visit = (at: unknown, path: string[]): void => { + if (at === null || typeof at !== 'object' || path.length > MAPPING_DEPTH) { + return; + } + if (isNode(at)) { + if ('_value' in at) { + found.push({ value: at, view, event, path }); + } else { + visit(at.x, [...path, 'x']); + visit(at.y, [...path, 'y']); + } + return; + } + for (const [key, inner] of Object.entries(at)) { + visit(inner, [...path, key]); + } + }; + visit(mapping, []); + return found; +} + +function add(state: DriverState, attachments: readonly Attached[]): void { + for (const attachment of attachments) { + state.attached.set(attachment.value, [...(state.attached.get(attachment.value) ?? []), attachment]); + state.known.delete(attachment.value); + } + // Counted only now: a view woken as React Native made the value native (inside the attach) took it as still. + for (const attachment of attachments) { + moved(state, attachment.value); + } +} + +function remove(state: DriverState, attachments: readonly Attached[]): void { + for (const attachment of attachments) { + // Each attachment is removed once, after it was added. + const left = (state.attached.get(attachment.value) as Attached[]).filter((each) => each !== attachment); + state.attached.set(attachment.value, left); + if (left.length === 0) { + // Where the last event left it, JS may not know (whatever JS set while it was attached): it stays unknown + // until JS next animates or sets it. + state.known.delete(attachment.value); + } + } +} + +/** Reads an attachment's values; one that cannot be read loses track of every value. */ +function read(state: DriverState, mapping: () => unknown, view: unknown, event: unknown): Attached[] { + try { + return attachmentsIn(mapping(), view, event); + } catch { + state.lost = true; + return []; + } +} + +type Method = (this: unknown, ...args: unknown[]) => unknown; + +function mark(target: T, state: DriverState): T { + // Not enumerable (the default): spreading the object copies no state. + Object.defineProperty(target, KEY, { value: state }); + return target; +} + +function stateOn(target: object | undefined): DriverState | undefined { + return (target as { [KEY]?: DriverState } | undefined)?.[KEY]; +} + +function wrapEvents(prototype: Record, state: DriverState): void { + const attach = prototype.__attach as Method; + const detach = prototype.__detach as Method; + prototype.__attach = mark(function (this: { _argMapping?: unknown }, ...args: unknown[]) { + const result = attach.apply(this, args); + const attachments = read(state, () => (this._argMapping as unknown[] | undefined)?.[0], args[0], args[1]); + add(state, attachments); + state.events.set(this, [...(state.events.get(this) ?? []), attachments]); + return result; + }, state); + prototype.__detach = mark(function (this: object, ...args: unknown[]) { + // Detached with nothing attached: nothing to count. (A string in either list counts no object.) + // Stryker disable next-line ArrayDeclaration + const [attachments = [], ...rest] = state.events.get(this) ?? []; + state.events.set(this, rest); + remove(state, attachments); + return detach.apply(this, args); + }, state); + mark(prototype, state); +} + +function wrapAttach(target: Record, state: DriverState): void { + const attachNativeEvent = target.attachNativeEvent as Method; + target.attachNativeEvent = mark(function (this: unknown, ...args: unknown[]) { + const handle = attachNativeEvent.apply(this, args) as { detach?: unknown } | null | undefined; + const attachments = read(state, () => (args[2] as unknown[] | null | undefined)?.[0], args[0], args[1]); + add(state, attachments); + // A handle that cannot be wrapped keeps them counted for good: covered, never trusted. + try { + const detach = (handle as { detach: Method }).detach; + (handle as { detach: Method }).detach = function (this: unknown, ...rest: unknown[]) { + // Detached again: its attachments are gone already, and removing them again removes nothing. + remove(state, attachments); + return detach.apply(this, rest); + }; + } catch { + // Kept counted. + } + return handle; + }, state); + mark(target, state); +} + +/** Wakes what watches `value`, at once: before React Native hands the change to the UI thread. */ +function moved(state: DriverState, value: object): void { + const wakes = state.onMove.get(value); + if (wakes === undefined) { + return; + } + // A snapshot: a watcher may stop watching, or watch again, as it is woken. + for (const wake of [...wakes]) { + try { + wake(); + } catch { + // A watcher's failure is its own; React Native's animation goes on. + } + } +} + +/** + * The `AnimatedValue` methods by which JS moves a value natively: starting an + * animation, setting or resetting it, changing its offset. Each is followed + * where React Native has it. + */ +const MOVES = ['animate', 'setValue', 'resetAnimation', 'setOffset', 'flattenOffset', 'extractOffset']; + +function wrapNative(prototype: Record, state: DriverState): void { + const makeNative = prototype.__makeNative as Method; + prototype.__makeNative = mark(function (this: { __isNative?: unknown }, ...args: unknown[]) { + const becoming = this.__isNative !== true; + const result = makeNative.apply(this, args); + if (becoming) { + state.known.add(this); + moved(state, this); + } + return result; + }, state); + for (const name of MOVES.filter((each) => typeof prototype[each] === 'function')) { + const move = prototype[name] as Method; + prototype[name] = mark(function (this: object, ...args: unknown[]) { + const result = move.apply(this, args); + // Set from JS: where it is now, JS has. (An event attached to it still moves it, and is told first.) + state.known.add(this); + moved(state, this); + return result; + }, state); + } + mark(prototype, state); +} + +/** What React Native has to follow; each from its own module. */ +export interface DriverSources { + /** The `AnimatedEvent` class. */ + readonly eventClass: unknown; + /** `AnimatedImplementation`: what ScrollView calls `attachNativeEvent` on. */ + readonly implementation: unknown; + /** The public `Animated` object (a copy of `AnimatedImplementation`'s fields), where it can be loaded. */ + readonly exported?: unknown; + /** The `AnimatedValue` class. */ + readonly valueClass: unknown; +} + +function prototypeOf(type: unknown): unknown { + return (type as { prototype?: unknown } | null | undefined)?.prototype; +} + +function withMethods(target: unknown, ...names: string[]): Record | undefined { + const record = target as Record | null | undefined; + return record != null && names.every((name) => typeof record[name] === 'function') ? record : undefined; +} + +/** + * Follows what drives native values from now on, through `sources`; once + * per object, sharing the state any earlier copy of this module set up. + * Anything missing follows nothing: no still native value is trusted. + * Returns whether everything is followed. + */ +export function followDrivers(sources: DriverSources): boolean { + const events = withMethods(prototypeOf(sources.eventClass), '__attach', '__detach'); + const values = withMethods(prototypeOf(sources.valueClass), '__makeNative'); + const implementation = withMethods(sources.implementation, 'attachNativeEvent'); + if (events === undefined || values === undefined || implementation === undefined) { + drivers = null; + return false; + } + const exported = withMethods(sources.exported, 'attachNativeEvent'); + const state = [events, values, implementation, exported].map(stateOn).find((found) => found !== undefined) ?? { + attached: new WeakMap(), + events: new WeakMap(), + known: new WeakSet(), + onMove: new WeakMap(), + lost: false, + }; + if (stateOn(events) === undefined) { + wrapEvents(events, state); + } + if (stateOn(values) === undefined) { + wrapNative(values, state); + } + for (const target of [implementation, exported]) { + // Once per object: a function set over the wrapper since (an app's own, calling ours) is not wrapped again. + // The public object may hold the wrapper already, spread from `AnimatedImplementation` once it was wrapped. + const unwrapped = target !== undefined && stateOn(target.attachNativeEvent as object) === undefined; + if (unwrapped && stateOn(target) === undefined) { + wrapAttach(target, state); + } + } + drivers = state; + return true; +} + +/** Stops following (nothing is trusted from then on); for tests. */ +export function forgetDrivers(): void { + drivers = null; +} + +/** + * The native events attached to `node` now (none: an empty list), or + * `undefined` when that cannot be known. + */ +export function eventsOn(node: object): readonly Attachment[] | undefined { + if (drivers === null || drivers.lost) { + return undefined; + } + return drivers.attached.get(node) ?? []; +} + +/** Whether only animations have moved `node` since it was made native, as far as followed. */ +export function knownDriven(node: object): boolean { + return drivers?.known.has(node) === true; +} + +/** `node` is seen animating: from now on, only animations move it until an event is attached. */ +export function noteAnimating(node: object): void { + drivers?.known.add(node); +} + +/** + * Calls `wake` the moment JS moves `node` (starts an animation on it, sets, + * resets or offsets it) or makes it native (its first animation or event + * with the native driver, which commits nothing), synchronously, until the + * returned function is called; `undefined` when that is not followed. + */ +export function whenMoved(node: object, wake: () => void): (() => void) | undefined { + const state = drivers; + if (state === null) { + return undefined; + } + let wakes = state.onMove.get(node); + if (wakes === undefined) { + wakes = new Set(); + state.onMove.set(node, wakes); + } + const subscription = (): void => wake(); + wakes.add(subscription); + const from = wakes; + return () => { + from.delete(subscription); + }; +} diff --git a/packages/react-native/src/secure/followEvents.ts b/packages/react-native/src/secure/followEvents.ts new file mode 100644 index 00000000..c19bf90b --- /dev/null +++ b/packages/react-native/src/secure/followEvents.ts @@ -0,0 +1,57 @@ +/** + * Starts following what drives native `Animated` values (`drivers.ts`) as + * soon as it is evaluated. The package entry imports it for its effect alone, + * which Metro's `inlineRequires` leaves eager from the entry's first + * evaluation: with `inlineRequires`, that is the app's first use of + * `@bugsee/react-native` (import it, or call `Bugsee.launch`, at the top of + * `index.js` to follow from the start). An event attached by a screen that + * mounts before then is not seen; the value it drives was made native + * unfollowed, so it is not trusted until JS next animates or sets it. + * + * React Native's own modules, where an app has them; anywhere they cannot be + * loaded (a test environment, or React Native moved them), nothing is + * followed, and no still native value is trusted. + * + * Each `require` has a literal path (Metro bundles nothing else) and sits + * directly in a `try` block: Metro then takes it as an optional dependency + * (`allowOptionalDependencies`, on by default in React Native's and Expo's + * Metro configs), so a file a future React Native moved builds into the + * bundle as a `require` that throws at startup, caught here, rather than + * failing the build. An app that turns optional dependencies off would fail + * its build instead; `scripts/check-rn-compat.sh` guards the four paths on + * every supported React Native first. + */ +/* eslint-disable @typescript-eslint/no-require-imports -- React Native's internals, where they are */ +import { followDrivers } from './drivers'; + +type Module = Record; + +// An empty catch leaves that source missing, which follows nothing. +// Stryker disable all +let eventClass: unknown; +try { + eventClass = (require('react-native/Libraries/Animated/AnimatedEvent') as Module).AnimatedEvent; +} catch { + // Not there: nothing followed. +} +let implementation: unknown; +try { + implementation = (require('react-native/Libraries/Animated/AnimatedImplementation') as Module).default; +} catch { + // As above. +} +let exported: unknown; +try { + exported = (require('react-native/Libraries/Animated/Animated') as Module).default; +} catch { + // As above. +} +let valueClass: unknown; +try { + valueClass = (require('react-native/Libraries/Animated/nodes/AnimatedValue') as Module).default; +} catch { + // As above. +} + +followDrivers({ eventClass, implementation, exported, valueClass }); +// Stryker restore all diff --git a/packages/react-native/src/secure/measureLoop.ts b/packages/react-native/src/secure/measureLoop.ts index 1e8f07a3..bf962fc2 100644 --- a/packages/react-native/src/secure/measureLoop.ts +++ b/packages/react-native/src/secure/measureLoop.ts @@ -129,7 +129,9 @@ function requestFrame(callback: () => void): () => void { 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). */ + /** Whether any subscriber wants its geometry; one watching its props alone does not measure it. */ + geometry: boolean; + /** Where the node was at the last frame (or when first watched); unmeasured, always the same. */ last: string; /** Its two fibers' props objects then: a commit that changes either may change how it clips. */ props: readonly unknown[]; @@ -168,7 +170,7 @@ function propsOf(fiber: FiberLike): unknown[] { */ function watchNodes(): void { for (const [fiber, node] of [...watched]) { - const now = whereNow(fiber); + const now = node.geometry ? whereNow(fiber) : node.last; const props = propsOf(fiber); const changed = now !== node.last || props.some((value, index) => value !== node.props[index]); node.last = now; @@ -188,6 +190,7 @@ function frame(): void { measurePass(() => publishTogether(() => { watchNodes(); + watchValues(); for (const { measure } of [...frameMeasurers]) { try { measure(); @@ -199,7 +202,7 @@ function frame(): void { ); } finally { // In a finally: nothing thrown above (a warn included) may stop the loop. - if (frameMeasurers.size > 0 || watched.size > 0) { + if (frameMeasurers.size > 0 || watched.size > 0 || watchedValues.size > 0) { cancelFrame ??= requestFrame(frame); } } @@ -207,16 +210,22 @@ function frame(): void { /** * 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. + * else than the frame before, or its props changed, until the returned + * function is called. Every subscriber of one fiber shares one measurement a + * frame, and so does every fiber of one host. With `geometry` false the + * subscriber wants its props alone (a `` host hidden by a commit), and + * the node is measured only if another subscriber wants that. */ -export function watchNode(fiber: FiberLike, moved: () => void): () => void { +export function watchNode(fiber: FiberLike, moved: () => void, geometry = true): () => 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) }; + // Measured at once: a move before the next frame is a move. Unmeasured, `last` never changes. + // Stryker disable next-line StringLiteral -- so any constant is as good + node = { subscribers: new Set(), geometry, last: geometry ? whereNow(fiber) : '', props: propsOf(fiber) }; watched.set(fiber, node); + } else if (geometry && !node.geometry) { + node.geometry = true; + node.last = whereNow(fiber); } const subscription = { moved }; node.subscribers.add(subscription); @@ -231,8 +240,66 @@ export function watchNode(fiber: FiberLike, moved: () => void): () => void { }; } +interface WatchedValue { + readonly subscribers: Set<{ readonly changed: () => void }>; + /** The node's running animation and value at the last frame. */ + state: readonly unknown[]; +} + +/** `Animated` values watched for an animation starting, or becoming native, by node. */ +const watchedValues = new Map(); + +function stateOf(node: object): unknown[] { + const { + _animation: animation, + _value: value, + __isNative: native, + } = node as { _animation?: unknown; _value?: unknown; __isNative?: unknown }; + // Made native by its first animation with the native driver, which commits nothing. + return [animation, value, native]; +} + +/** Wakes the subscribers of every watched value whose animation or value changed. */ +function watchValues(): void { + for (const [node, entry] of [...watchedValues]) { + const state = stateOf(node); + const changed = state.some((part, index) => part !== entry.state[index]); + entry.state = state; + if (changed) { + for (const { changed: wake } of [...entry.subscribers]) { + wake(); + } + } + } +} + +/** + * Reads `node` (an `Animated.Value`) every frame, and calls `changed` + * whenever an animation starts or ends on it, it is made native, or its value moves, until the + * returned function is called: a view under a still native transform needs no + * measuring until then. A few property reads a frame for every subscriber. + */ +export function watchValue(node: object, changed: () => void): () => void { + let entry = watchedValues.get(node); + if (entry === undefined) { + entry = { subscribers: new Set(), state: stateOf(node) }; + watchedValues.set(node, entry); + } + const subscription = { changed }; + entry.subscribers.add(subscription); + cancelFrame ??= requestFrame(frame); + const target = entry; + return () => { + target.subscribers.delete(subscription); + if (target.subscribers.size === 0 && watchedValues.get(node) === target) { + watchedValues.delete(node); + } + stopFramesIfIdle(); + }; +} + function stopFramesIfIdle(): void { - if (frameMeasurers.size === 0 && watched.size === 0) { + if (frameMeasurers.size === 0 && watched.size === 0 && watchedValues.size === 0) { cancelFrame?.(); cancelFrame = undefined; } diff --git a/packages/react-native/src/secure/nativeAnimation.ts b/packages/react-native/src/secure/nativeAnimation.ts new file mode 100644 index 00000000..bbe1a85d --- /dev/null +++ b/packages/react-native/src/secure/nativeAnimation.ts @@ -0,0 +1,1005 @@ +import { + SCROLL_HOST_TYPES, + clipToAncestors, + contentOf, + isReanimatedComponent, + measureFiber, + passMemo, + styleValue, +} from './ancestry'; +import { eventsOn, isNode, knownDriven, noteAnimating } from './drivers'; +import type { Attachment } from './drivers'; +import './followEvents'; +import { WHOLE_SURFACE, keyOf, platformOS } from './transition'; +import type { Ancestry } from './ancestry'; +import { FiberTag } from '../viewtree/fiber'; +import type { FiberLike, WindowRect } from '../viewtree/fiber'; + +/** + * Where native-driven transforms above a `` can draw it. + * + * With `useNativeDriver: true` an `Animated` value is applied natively, every + * frame, and never reaches the shadow tree as it moves: `measureInWindow` + * keeps reading what React last rendered (or, with React Native's C++ + * Animated, the value the last animation ended at), while the view is drawn + * somewhere else. What JS does know is the `Animated` nodes themselves, in + * the props of the `Animated` component that renders the transformed host. + * From them this module bounds every value a node can take until the next + * re-measure, by interval arithmetic: + * - an `Animated.Value` in a timing animation: its whole path, easing + * overshoot included; still: its value, but only when JS provably has it. + * Natively, only animations (which JS starts, and which hand JS their end + * value) and native events (a scroll, a gesture, whose values JS never + * sees) move a value; `drivers.ts` follows which events are attached to + * which value, and which values were made native while it followed. A + * value an event is attached to is wherever the event takes it, animated + * or not: on Android, a ScrollView's `contentOffset` within the offsets + * that ScrollView can reach; anything else, anywhere. A value whose driver + * is not known (made native before the package loaded, or left where its + * last event took it, and not seen animating since) is anywhere too, and + * so is every native value where nothing can be followed; + * - `diffClamp` between its bounds; `add`, `subtract`, `multiply`, `divide` + * and `modulo` from their operands; an interpolation through its mapping, + * or, whatever its input, within its outputs (each segment as far as its + * easing takes it) when neither end runs on with its input: both clamp, + * or extend a flat last segment. + * + * A transform of translations and scales (in any order, about its + * `transformOrigin`) is then bounded exactly: the view's measured rectangle is + * taken back to the host's own frame under each value the shadow tree may + * hold (the rendered one, or the current one), and carried through every + * value the nodes can take. The result is joined with the measured rectangle, + * so a mask is only ever widened, never moved off the view. + * + * Anything else is unbounded and covers the whole surface, cut by the + * clipping ancestors above it: a rotation, skew or matrix; a node of a kind, + * or with fields, this module does not know (React Native renamed something); + * a spring or decay; a value a native event is attached to, or whose + * driver is not known, beyond what is said above; a Reanimated component + * whose style can move the view (an animated style that sets a transform, a + * shared value set inline, a CSS animation or transition: Reanimated keeps + * its values on the UI thread). + * Masking too much beats drawing the view where nothing masks it. + * + * A JS-driven transform at rest needs none of this: Fabric commits its + * values to the shadow tree, and `measureInWindow` follows it. While a JS + * animation runs on it (`_animation` or `_tracking` set on a root), though, + * it moves the view every frame with no wrapped call, and a step can be drawn + * before the frame that measures it: it is then bounded over the animation's + * path like a native one, and the view re-measured every frame; once it + * stops, back to the tick. + */ + +export interface Range { + lo: number; + hi: number; +} + +/** A node's bound, and what about it calls for watching. */ +interface NodeBound { + /** Every value it can take until the next re-measure; `undefined`: unbounded. */ + readonly range: Range | undefined; + /** A timing animation drives it: its value changes every frame. */ + readonly running: boolean; + /** The native `Animated.Value`s it is computed from. */ + readonly roots: readonly AnimatedNodeLike[]; +} + +/** One `Animated` component's transform, as it can move the view. */ +export interface MotionLevel { + /** Where the component is in `Ancestry.path`: clippers below it move with it. */ + readonly index: number; + /** Every place `rect` (measured, in the window) can be drawn; `undefined`: anywhere. */ + readonly move: (rect: WindowRect) => WindowRect | undefined; +} + +/** What native-driven transforms above the view do to it. */ +export interface NativeMotion { + /** Nearest first. */ + readonly levels: readonly MotionLevel[]; + /** A native animation is running: where the view can be keeps changing. */ + readonly running: boolean; + /** The native values the bounds came from, to watch for an animation starting. */ + readonly roots: readonly AnimatedNodeLike[]; +} + +/** How finely an easing or an interpolation is sampled. */ +const SAMPLES = 20; +/** How much wider a sampled curve that turns back is taken to reach: a peak can fall between samples. */ +const TURN_PAD = 0.1; + +const TRANSLATE_AXES: Readonly> = { translateX: 'x', translateY: 'y' }; +const SCALE_AXES: Readonly>> = { + scale: ['x', 'y'], + scaleX: ['x'], + scaleY: ['y'], +}; +/** Functions that, at these values, do nothing; anything else they do is unbounded. */ +const IDENTITY: Readonly> = { + rotate: '0deg', + rotateX: '0deg', + rotateY: '0deg', + rotateZ: '0deg', + skewX: '0deg', + skewY: '0deg', +}; + +export interface AnimatedNodeLike { + __getValue: () => unknown; + [field: string]: unknown; +} + +/** + * Whether `node` may be driven natively. A node without the flag at all is + * of a kind this module does not know: taken as native. + */ +function isNativeNode(value: unknown): value is AnimatedNodeLike { + return isNode(value) && (!('__isNative' in value) || value.__isNative === true); +} + +function finiteNumber(value: unknown): value is number { + return Number.isFinite(value as number); +} + +function hull(values: readonly number[]): Range { + return { lo: Math.min(...values), hi: Math.max(...values) }; +} + +/** + * `values` sampled in order along a curve, widened by {@link TURN_PAD} of its + * span when an extreme is not at an end: the curve turns back, and its peak + * can fall between samples. + */ +function sampled(values: readonly unknown[]): Range { + const range = hull(values as number[]); + const ends = hull([values[0] as number, values[values.length - 1] as number]); + if (range.lo === ends.lo && range.hi === ends.hi) { + return range; + } + const pad = (range.hi - range.lo) * TURN_PAD; + return { lo: range.lo - pad, hi: range.hi + pad }; +} + +const UNBOUNDED: NodeBound = { range: undefined, running: false, roots: [] }; + +/** + * An `Animated.Value`: its running timing animation's path, or its value if + * JS provably has it. A native value is wherever the events attached to it + * take it (within a ScrollView's reach, for its offset; otherwise anywhere), + * and anywhere when what drives it is not known (`drivers.ts`). + */ +function valueBound(node: AnimatedNodeLike): NodeBound { + // Fields this module depends on, by name: renamed, nothing here can be trusted. + if (!('_value' in node) || !('_offset' in node) || !('_animation' in node)) { + return UNBOUNDED; + } + const now = node.__getValue(); + const offset = node._offset; + if (!finiteNumber(now) || !finiteNumber(offset) || node._tracking != null) { + return { ...UNBOUNDED, roots: [node] }; + } + const animation = node._animation as Record | null; + // Driven from JS, it is in the shadow tree already: what JS holds is the truth. + if (node.__isNative !== false) { + const events = eventsOn(node); + if (events === undefined || events.length > 0) { + // An event moves it as well as any animation, wherever the gesture or the scroll takes it. + const reach = events !== undefined && animation == null ? scrollRange(events) : undefined; + const range = reach === undefined ? undefined : hull([now, reach.lo + offset, reach.hi + offset]); + return { range, running: animation != null, roots: [node] }; + } + if (animation != null) { + noteAnimating(node); + } else if (!knownDriven(node)) { + // Made native before the package loaded, or left where an event took it: anything may drive it. + return { range: undefined, running: false, roots: [node] }; + } + } + if (animation == null) { + return { range: { lo: now, hi: now }, running: false, roots: [node] }; + } + const { _fromValue: from, _toValue: to, _easing: easing } = animation; + if (!finiteNumber(from) || !finiteNumber(to) || typeof easing !== 'function') { + return { range: undefined, running: true, roots: [node] }; + } + const values = [now]; + const path: number[] = []; + for (let step = 0; step <= SAMPLES; step += 1) { + const eased = (easing as (t: number) => unknown)(step / SAMPLES); + if (!finiteNumber(eased)) { + return { range: undefined, running: true, roots: [node] }; + } + path.push(offset + from + eased * (to - from)); + } + const along = sampled(path); + values.push(along.lo, along.hi); + return { range: hull(values), running: true, roots: [node] }; +} + +/** + * How far past either end of its content an Android ScrollView can be + * scrolled: a fling runs on past an end by the overfling distance before it + * springs back; a drag, a fling's end, `scrollTo` and `contentOffset` are + * held within the content. The overfling distance is AOSP's + * `ViewConfiguration` (6 dp), which JS cannot read: an assumption, taken + * about ten times over for a device whose framework changed it. Only a + * ScrollView whose hosts (itself and above) are scaled by 1 or -1 is + * bounded: under any other scale its offsets are not in window units. + */ +export const SCROLL_OVERRUN = 64; + +/** + * Where native scroll events can set a value they are attached to: within + * the reach of each ScrollView, for its `contentOffset`, measured now; or + * `undefined` when any of them is something else, or cannot be measured. + * On Android only: an iOS ScrollView bounces past its ends, and its + * `scrollTo` goes wherever it is told. + */ +function scrollRange(events: readonly Attachment[]): Range | undefined { + if (platformOS() !== 'android') { + return undefined; + } + let range: Range | undefined; + for (const event of events) { + const reach = scrollReach(event); + if (reach === undefined) { + return undefined; + } + range = hull([reach.lo, reach.hi, range?.lo ?? reach.lo, range?.hi ?? reach.hi]); + } + return range; +} + +function scrollReach(attachment: Attachment): Range | undefined { + const { event, path } = attachment; + const along = path[2] === 'x' ? 'width' : path[2] === 'y' ? 'height' : undefined; + if (event !== 'onScroll' || path.length !== 3 || path[0] !== 'nativeEvent' || path[1] !== 'contentOffset') { + return undefined; + } + const scroller = scrollHostOf(attachment); + // Android renders a vertical ScrollView's content through the `View` component: its host is further down. + const content = scroller === undefined ? undefined : contentOf(scroller); + if (along === undefined || content === undefined || scaledFrom(scroller as FiberLike)) { + return undefined; + } + const viewport = measureFiber(scroller as FiberLike); + const inner = measureFiber(content); + if (viewport == null || inner == null) { + return undefined; + } + return { lo: -SCROLL_OVERRUN, hi: Math.max(0, inner[along] - viewport[along]) + SCROLL_OVERRUN }; +} + +/** How far up from a ScrollView its hosts are read for a scale; a cycle in a malformed tree must end. */ +const MAX_ANCESTORS = 10_000; + +/** + * Whether `scroller`, or a host above it, has a committed transform that + * scales by anything but 1 or -1 (or by what cannot be read): its offsets, + * in its own units, are then not the window units it is measured in. + */ +function scaledFrom(scroller: FiberLike): boolean { + let fiber: FiberLike | null = scroller; + // One fiber more or less before giving up on a malformed tree changes nothing. + // Stryker disable next-line EqualityOperator + for (let depth = 0; fiber != null && depth < MAX_ANCESTORS; depth += 1) { + if (fiber.tag === FiberTag.HostComponent && scalesBy(fiber.memoizedProps, (by) => Math.abs(by as number) !== 1)) { + return true; + } + fiber = fiber.return; + } + return false; +} + +/** The ScrollView host each attachment was made to (`null`: none found), looked for once. */ +const scrollHosts = new WeakMap(); + +/** The fiber root the running measure searches for a host by its tag; `null` outside one. */ +let searchRoot: { current?: unknown } | null = null; + +function scrollHostOf(attachment: Attachment): FiberLike | undefined { + let host = scrollHosts.get(attachment); + // Looking again finds the same host, remembered by its tag: only work. + // Stryker disable next-line ConditionalExpression + if (host === undefined) { + const { view } = attachment; + const handle = (view as { __internalInstanceHandle?: unknown } | null | undefined)?.__internalInstanceHandle; + // An `Animated.ScrollView` attaches its events to its scrollable node's tag; RN's own ScrollView, to its host. + const tag = typeof view === 'number' ? view : (view as { __nativeTag?: unknown } | null | undefined)?.__nativeTag; + // No host has no tag: looking for none finds none. + // Stryker disable next-line ConditionalExpression + const found = isFiber(handle) ? handle : typeof tag === 'number' ? hostWithTag(tag) : null; + if (found === undefined) { + // No tree to look in yet: looked for again next time. + return undefined; + } + host = found !== null && SCROLL_HOST_TYPES.has(found.type) ? found : null; + // As above. + // Stryker disable next-line CallExpression + scrollHosts.set(attachment, host); + } + return host ?? undefined; +} + +function isFiber(value: unknown): value is FiberLike { + return value !== null && typeof value === 'object' && typeof (value as { tag?: unknown }).tag === 'number'; +} + +/** A host's native tag: Fabric's `canonical.nativeTag`, or Paper's `_nativeTag`; `null` when it has neither. */ +function tagOf(fiber: FiberLike): unknown { + const node = fiber.stateNode as { canonical?: { nativeTag?: unknown }; _nativeTag?: unknown } | null | undefined; + return node?.canonical?.nativeTag ?? node?._nativeTag ?? null; +} + +/** Hosts found by tag, while they still carry it. */ +const tagged = new Map(); +/** How many hosts are remembered by tag; past it, they are looked for again. */ +const TAGGED_MAX = 32; +/** A cycle in a malformed tree must not spin forever. */ +const MAX_SEARCH = 100_000; + +/** + * The committed host with native tag `tag` in the tree being measured; + * `null` when it is not there, `undefined` when there is no tree to look in. + */ +function hostWithTag(tag: number): FiberLike | null | undefined { + const known = tagged.get(tag); + if (known !== undefined && tagOf(known) === tag) { + return known; + } + const top = searchRoot?.current; + if (!isFiber(top)) { + return undefined; + } + const pending: FiberLike[] = [top]; + // One fiber more or less before giving up on a malformed tree changes nothing. + // Stryker disable next-line EqualityOperator + for (let visited = 0; pending.length > 0 && visited < MAX_SEARCH; visited += 1) { + const fiber = pending.pop() as FiberLike; + if (fiber.tag === FiberTag.HostComponent && tagOf(fiber) === tag) { + // Bounds the memory alone: forgetting them all, or one more or less, only costs searching again. + // Stryker disable next-line ConditionalExpression,EqualityOperator,BlockStatement,CallExpression + if (tagged.size >= TAGGED_MAX) { + // Stryker disable next-line CallExpression -- as above + tagged.clear(); + } + tagged.set(tag, fiber); + return fiber; + } + for (let child = fiber.child; child != null; child = child.sibling) { + pending.push(child); + } + } + return null; +} + +/** + * How an interpolation extends past an end of its input range, as React + * Native reads its config: anything but `clamp` and `identity` (`undefined` + * included, its default) extends. + */ +function extrapolation(config: Record, side: 'extrapolateLeft' | 'extrapolateRight'): unknown { + return config[side] !== undefined ? config[side] : config.extrapolate; +} + +/** Where an easing takes a segment, from its start (0) to its end (1); `undefined` when it cannot be read. */ +function easedSpan(easing: unknown): Range | undefined { + if (easing == null) { + return { lo: 0, hi: 1 }; + } + if (typeof easing !== 'function') { + return undefined; + } + const values: unknown[] = []; + for (let step = 0; step <= SAMPLES; step += 1) { + values.push((easing as (t: number) => unknown)(step / SAMPLES)); + } + return values.every(finiteNumber) ? sampled(values) : undefined; +} + +/** + * Every output an interpolation can give, whatever its input, when neither + * end runs on with its input: an end that clamps, or that extends a flat + * last segment (React Native gives a segment whose two outputs are equal + * that output, eased or not); `undefined` otherwise. Within each segment, + * the whole span of its easing. + */ +function outputHull(config: Record): Range | undefined { + const outputs = config.outputRange; + if (!Array.isArray(outputs) || outputs.length < 2 || !outputs.every(finiteNumber)) { + return undefined; + } + const last = outputs.length - 1; + // An end that extends (React Native does for anything but these two) runs on unless its segment is flat. + const holds = (how: unknown, from: number, to: number): boolean => + how === 'clamp' || (how !== 'identity' && from === to); + const eased = easedSpan(config.easing); + if ( + eased === undefined || + !holds(extrapolation(config, 'extrapolateLeft'), outputs[0] as number, outputs[1] as number) || + !holds(extrapolation(config, 'extrapolateRight'), outputs[last - 1] as number, outputs[last] as number) + ) { + return undefined; + } + // Each segment, from its start: a flat one gives that output, eased or not; an end held gives its segment's. + const values = (outputs as number[]).slice(0, -1).flatMap((from, index) => { + const to = outputs[index + 1] as number; + return [from + eased.lo * (to - from), from + eased.hi * (to - from)]; + }); + return hull(values); +} + +/** + * An interpolation: through its mapping; or, whatever its input, within its + * outputs when neither end runs on with its input (`outputHull`). + */ +function interpolationBound(node: AnimatedNodeLike): NodeBound { + const input = nodeBound(node._parent as AnimatedNodeLike); + const config = (node._config ?? {}) as Record; + const held = outputHull(config); + const interpolate = (node._getInterpolation as (() => unknown) | undefined)?.(); + if (input.range === undefined || typeof interpolate !== 'function') { + return { ...input, range: held }; + } + const { lo, hi } = input.range; + // Stryker disable next-line ArrayDeclaration -- the filter below drops anything that is not a number + const breakpoints = Array.isArray(config.inputRange) ? (config.inputRange as unknown[]) : []; + // The ends are sampled anyway. + // Stryker disable next-line EqualityOperator -- so taking them in as breakpoints too changes nothing + const samples = breakpoints.filter((point): point is number => finiteNumber(point) && point > lo && point < hi); + for (let step = 0; step <= SAMPLES; step += 1) { + samples.push(lo + ((hi - lo) * step) / SAMPLES); + } + samples.sort((a, b) => a - b); + const values = samples.map((sample) => (interpolate as (input: number) => unknown)(sample)); + if (!values.every(finiteNumber)) { + return { ...input, range: held }; + } + return { ...input, range: sampled(values as number[]) }; +} + +function join(...bounds: NodeBound[]): Pick { + return { running: bounds.some((bound) => bound.running), roots: bounds.flatMap((bound) => bound.roots) }; +} + +type Operation = (x: number, y: number) => number; + +const divide: Operation = (x, y) => x / y; +const OPERATIONS: readonly Operation[] = [(x, y) => x + y, (x, y) => x - y, (x, y) => x * y, divide]; + +/** + * Which operations `node` (with operands `_a` and `_b`) can be: `divide` by + * its own field; otherwise those whose result on the operands' values now is + * the node's value now (`add`, `subtract` and `multiply` keep the same + * fields). Where several match (`x * 1` and `x / 1`), each is taken. None: + * a kind this module does not know. + */ +function operationsOf(node: AnimatedNodeLike): readonly Operation[] { + if ('_warnedAboutDivideByZero' in node) { + return [divide]; + } + const x = (node._a as AnimatedNodeLike).__getValue(); + const y = (node._b as AnimatedNodeLike).__getValue(); + const value = node.__getValue(); + return OPERATIONS.filter((operation) => operation(x as number, y as number) === value); +} + +/** `add`, `subtract`, `multiply`, `divide`: from the operands, by intervals. */ +function binaryBound(node: AnimatedNodeLike): NodeBound { + const a = nodeBound(node._a as AnimatedNodeLike); + const b = nodeBound(node._b as AnimatedNodeLike); + const both = join(a, b); + const operations = isNode(node._a) && isNode(node._b) ? operationsOf(node) : []; + if (a.range === undefined || b.range === undefined || operations.length === 0) { + return { range: undefined, ...both }; + } + const as = [a.range.lo, a.range.hi]; + const bs = [b.range.lo, b.range.hi]; + if (operations.includes(divide) && b.range.lo <= 0 && b.range.hi >= 0) { + return { range: undefined, ...both }; + } + const results = as.flatMap((x) => bs.flatMap((y) => operations.map((operation) => operation(x, y)))); + return { range: hull(results), ...both }; +} + +/** + * Every value `node` (a number or an `Animated` node) can take until the next + * re-measure, whether a timing animation drives it, and the native values it + * comes from. Computed once per node a measure pass. + */ +export function nodeBound(node: unknown): NodeBound { + if (finiteNumber(node)) { + return { range: { lo: node, hi: node }, running: false, roots: [] }; + } + if (!isNode(node)) { + return UNBOUNDED; + } + return passMemo(node, () => { + // Every kind told apart by the fields that are its own. + if ('_parent' in node) { + return interpolationBound(node); + } + if ('_min' in node) { + const input = nodeBound(node._a); + const { _min: min, _max: max } = node; + return { ...input, range: finiteNumber(min) && finiteNumber(max) ? { lo: min, hi: max } : undefined }; + } + if ('_modulus' in node) { + const input = nodeBound(node._a); + const modulus = node._modulus; + return { ...input, range: finiteNumber(modulus) ? hull([0, modulus]) : undefined }; + } + if ('_b' in node) { + return binaryBound(node); + } + return valueBound(node); + }); +} + +/** The transform functions of `props.style`, as `[name, value]`; `null` when there is no list. */ +function transformOf(props: unknown): Array<[string, unknown]> | null { + const transform = styleValue((props as { style?: unknown } | null | undefined)?.style, 'transform'); + if (!Array.isArray(transform)) { + return null; + } + return transform.flatMap((entry) => + entry !== null && typeof entry === 'object' ? Object.entries(entry as Record) : [['?', entry]], + ) as Array<[string, unknown]>; +} + +/** One axis of an axis-aligned affine map: `q -> scale * q + shift`. */ +interface Affine { + scale: number; + shift: number; +} + +/** A transform origin on one axis: a fraction of the size plus a length. */ +interface Origin { + fraction: number; + length: number; +} + +const KEYWORDS: Readonly> = { + left: { fraction: 0, length: 0 }, + top: { fraction: 0, length: 0 }, + center: { fraction: 0.5, length: 0 }, + right: { fraction: 1, length: 0 }, + bottom: { fraction: 1, length: 0 }, +}; + +function originPart(part: unknown): Origin | undefined { + const keyword = KEYWORDS[part as string]; + if (keyword !== undefined) { + return keyword; + } + const match = /^(-?\d+(?:\.\d+)?)(%|px)?$/.exec(String(part)); + if (match === null) { + return undefined; + } + const amount = Number(match[1]); + return match[2] === '%' ? { fraction: amount / 100, length: 0 } : { fraction: 0, length: amount }; +} + +/** The host's `transformOrigin`, per axis (the centre when unset); `undefined` when it cannot be read. */ +export function originOf(props: unknown): { x: Origin; y: Origin } | undefined { + const origin = styleValue((props as { style?: unknown } | null | undefined)?.style, 'transformOrigin'); + if (origin === undefined) { + return { x: KEYWORDS.center as Origin, y: KEYWORDS.center as Origin }; + } + if (!Array.isArray(origin) && typeof origin !== 'string') { + return undefined; + } + const parts: unknown[] = Array.isArray(origin) ? origin : origin.trim().split(/\s+/); + // An empty list names no axis, as an unknown word does, below. + if (parts.length > 3) { + return undefined; + } + const [first, second = 'center'] = parts; + // CSS lets the keywords come either way round, and one alone name either axis. + const swapped = first === 'top' || first === 'bottom' || second === 'left' || second === 'right'; + const x = originPart(swapped ? second : first); + const y = originPart(swapped ? first : second); + return x === undefined || y === undefined ? undefined : { x, y }; +} + +interface Step { + axis: 'x' | 'y'; + /** A translation; otherwise a scale. */ + shift: boolean; + bound: NodeBound; + /** The value the host was rendered with; `undefined`: not in its props. */ + rendered: number | undefined; + /** The value its node holds in JS now. */ + current: number | undefined; +} + +/** The host an `Animated` component at `index` renders: the nearest host below it. */ +function hostBelow(ancestry: Ancestry, fibers: readonly FiberLike[], index: number): FiberLike { + for (let below = index - 1; below >= 0; below -= 1) { + if ((fibers[below] as FiberLike).tag === FiberTag.HostComponent) { + return fibers[below] as FiberLike; + } + } + return ancestry.self as FiberLike; +} + +/** Composes `steps` (innermost last, as CSS applies them) on one axis, each at `pick`'s value. */ +function compose(steps: readonly Step[], axis: 'x' | 'y', pick: (step: Step) => number): Affine { + let map: Affine = { scale: 1, shift: 0 }; + for (let index = steps.length - 1; index >= 0; index -= 1) { + const step = steps[index] as Step; + if (step.axis !== axis) { + continue; + } + const value = pick(step); + map = + step.shift + ? { scale: map.scale, shift: map.shift + value } + : { scale: map.scale * value, shift: map.shift * value }; + } + return map; +} + +/** `[lo, hi]` carried through `steps` on one axis, every value of every step. */ +function sweep(lo: number, hi: number, steps: readonly Step[], axis: 'x' | 'y'): Range { + let range = { lo, hi }; + for (let index = steps.length - 1; index >= 0; index -= 1) { + const step = steps[index] as Step; + if (step.axis !== axis) { + continue; + } + const by = step.bound.range as Range; + range = + step.shift + ? { lo: range.lo + by.lo, hi: range.hi + by.hi } + : hull([range.lo * by.lo, range.lo * by.hi, range.hi * by.lo, range.hi * by.hi]); + } + return range; +} + +/** + * The motion of one `Animated` component's transform; `null` when it has no + * `Animated` node in it. Nodes none of which is native yet move nothing now, + * but the first animation with the native driver makes them native and + * commits nothing: they are only watched (and with nothing to watch, the view + * is re-measured every frame). + */ +function levelOf(ancestry: Ancestry, fibers: readonly FiberLike[], index: number): NotNative | Level | Pending { + const fiber = fibers[index] as FiberLike; + if (isReanimatedComponent(fiber)) { + return reanimatedMoves(fiber.memoizedProps) ? UNBOUNDED_LEVEL : NOT_NATIVE; + } + const entries = transformOf(fiber.memoizedProps); + if (entries === null) { + return NOT_NATIVE; + } + const nodes = entries.filter(([, value]) => isNode(value)); + if (nodes.length === 0) { + return NOT_NATIVE; + } + if (!nodes.some(([, value]) => isNativeNode(value))) { + const roots = nodes.flatMap(([, value]) => nodeBound(value).roots); + // Driven from JS, an animation moves the view every frame (Fabric commits each step) with no wrapped call, and + // a step can be drawn before the frame that measures it: while one runs on a root it is bounded over its path, + // as a native one is, and followed every frame; once it stops, back to the tick. + if (!roots.some((root) => root._animation != null || root._tracking != null)) { + return { running: roots.length === 0, roots, move: undefined }; + } + } + const host = hostBelow(ancestry, fibers, index); + // By place: a function can come twice (`translateX` twice), each with its own value. + // A string in place of the list, or of an entry, names no transform function. + // Stryker disable next-line ArrayDeclaration + const rendered = transformOf(host.memoizedProps) ?? []; + const steps: Step[] = []; + const bounds: NodeBound[] = []; + let bounded = true; + for (const [place, [name, value]] of entries.entries()) { + const bound = nodeBound(value); + bounds.push(bound); + const axes = TRANSLATE_AXES[name] !== undefined ? [TRANSLATE_AXES[name]] : SCALE_AXES[name]; + if (axes === undefined) { + bounded &&= name in IDENTITY && value === IDENTITY[name]; + continue; + } + // Without this, sweeping a missing range throws, which reads as unbounded too. + // Stryker disable next-line AssignmentOperator,ConditionalExpression -- so its mutants are equivalent + bounded &&= bound.range !== undefined; + // Stryker disable next-line ArrayDeclaration -- as above + const [renderedName, at] = rendered[place] ?? []; + const now = isNode(value) ? value.__getValue() : value; + for (const axis of axes) { + steps.push({ + axis, + shift: TRANSLATE_AXES[name] !== undefined, + bound, + rendered: renderedName === name && finiteNumber(at) ? at : undefined, + current: finiteNumber(now) ? now : undefined, + }); + } + } + const { running, roots } = join(...bounds); + // Scaled up in the shadow tree: the levels inside it move the view by more than their own values. + const scaled = steps.some( + (step) => !step.shift && [step.rendered, step.current].some((at) => Math.abs(at ?? 1) > 1), + ); + // The host is rendered with the component's style, `transformOrigin` included. + const origin = originOf(host.memoizedProps); + // Without the second test, reading a missing origin throws, which reads as unbounded too. + // Stryker disable next-line ConditionalExpression -- so its mutant is equivalent + if (!bounded || origin === undefined || scrolledBy(ancestry, roots)) { + return { running, roots, move: () => undefined, scaled }; + } + return { running, roots, move: (rect) => moveThrough(rect, host, steps, origin), scaled }; +} + +/** + * Whether a value of a level is, or may be, the offset of a ScrollView whose + * content holds the view (a sticky header; or a transform around a + * ScrollView, driven by its own offset): the view then moves with that + * content as well, by the same offset, natively ahead of the shadow tree it is + * measured in, so bounding the transform alone does not bound where it is + * drawn. Unbounded: the whole surface, cut by the clipping ancestors above + * the level. Fails closed: a native value whose driver is not known (nothing + * followed, made native unfollowed, or a scroll whose ScrollView cannot be + * found) counts as such an offset whenever a ScrollView is above the view. + */ +function scrolledBy(ancestry: Ancestry, roots: readonly AnimatedNodeLike[]): boolean { + let keys: Set | undefined; + const holds = (scroller: FiberLike): boolean => (keys ??= new Set(ancestry.path.map(keyOf))).has(keyOf(scroller)); + const underScroll = (): boolean => + // Only a host has a ScrollView's (string) type: the tag only spares a lookup. + // Stryker disable next-line ConditionalExpression + ancestry.path.some((fiber) => fiber.tag === FiberTag.HostComponent && SCROLL_HOST_TYPES.has(fiber.type)); + return roots.some((root) => { + // Driven from JS: it moves no content natively. + if (root.__isNative === false) { + return false; + } + const events = eventsOn(root); + if (events === undefined || (events.length === 0 && !knownDriven(root))) { + return underScroll(); + } + return events.some((attachment) => { + const scroller = scrollHostOf(attachment); + if (scroller !== undefined) { + return holds(scroller); + } + // A scroll whose ScrollView is not found may be one of those above; a gesture, say, is no scroll. + return attachment.event === 'onScroll' && underScroll(); + }); + }); +} + +interface Level { + running: boolean; + roots: readonly AnimatedNodeLike[]; + move: (rect: WindowRect) => WindowRect | undefined; + /** Its transform scales the view up in the shadow tree (by more than 1, or less than -1). */ + scaled: boolean; +} +/** Nodes not native yet: watched, moving nothing. */ +interface Pending { + running: boolean; + roots: readonly AnimatedNodeLike[]; + move: undefined; +} +type NotNative = null; +const NOT_NATIVE: NotNative = null; +const UNBOUNDED_LEVEL: Level = { running: false, roots: [], move: () => undefined, scaled: false }; + +/** + * Every place `rect` (measured with the host's transform at whichever value + * the shadow tree holds) can be drawn as `steps` take every value they can. + */ +function moveThrough( + rect: WindowRect, + host: FiberLike, + steps: readonly Step[], + origin: { x: Origin; y: Origin }, +): WindowRect | undefined { + const box = measureFiber(host); + // Without this, reading a missing box throws, which reads as unbounded too. + // Stryker disable next-line ConditionalExpression,BlockStatement -- so its mutants are equivalent + if (box == null) { + return undefined; + } + // What the shadow tree may hold: the value React rendered, or the one the last animation ended at. + const bases: Array<(step: Step) => number | undefined> = [(step) => step.rendered, (step) => step.current]; + const axes: Range[] = []; + for (const axis of ['x', 'y'] as const) { + const start = axis === 'x' ? rect.x : rect.y; + const end = start + (axis === 'x' ? rect.width : rect.height); + const hostStart = axis === 'x' ? box.x : box.y; + const hostSize = axis === 'x' ? box.width : box.height; + const out = [start, end]; + for (const base of bases) { + if (steps.some((step) => base(step) === undefined)) { + continue; + } + const map = compose(steps, axis, (step) => base(step) as number); + // A scale of nothing, or a mirror: no frame to go back to. + if (map.scale <= 0) { + continue; + } + const at = origin[axis]; + // The origin, in the window: the host's measured box is its frame under `map` about it. + const pivot = hostStart + at.fraction * hostSize + map.scale * at.length - map.shift; + const local = [(start - pivot - map.shift) / map.scale, (end - pivot - map.shift) / map.scale]; + const reach = sweep(Math.min(...local), Math.max(...local), steps, axis); + out.push(pivot + reach.lo, pivot + reach.hi); + } + // No value of the shadow tree to go back from: nothing is known. + if (out.length === 2) { + return undefined; + } + axes.push(hull(out)); + } + const [x, y] = axes as [Range, Range]; + return { x: x.lo, y: y.lo, width: x.hi - x.lo, height: y.hi - y.lo }; +} + +/** Whether `value` looks like a Reanimated shared value, which moves on the UI thread. */ +function sharedValueLike(value: unknown): boolean { + const shared = value as { _isReanimatedSharedValue?: unknown; modify?: unknown } | null | undefined; + return ( + shared != null && + typeof shared === 'object' && + (shared._isReanimatedSharedValue === true || typeof shared.modify === 'function') + ); +} + +/** CSS animations and transitions (Reanimated 4) can move the view without a commit. */ +// No React Native style name has these words anywhere but at its start. +// Stryker disable next-line Regex +const CSS_MOTION = /^(transition|animation)/; + +/** + * Whether a Reanimated component's style can move the view natively: an + * animated style that sets a transform, a shared value set inline (in the + * style or its transform), or a CSS animation or transition. + */ +function reanimatedMoves(props: unknown): boolean { + const style = (props as { style?: unknown } | null | undefined)?.style; + const entries = Array.isArray(style) ? style.flat(Infinity) : [style]; + return entries.some((entry) => { + const animated = entry as { viewDescriptors?: unknown; initial?: { value?: unknown } } | null | undefined; + if (animated == null || typeof animated !== 'object') { + return false; + } + if (!('viewDescriptors' in animated)) { + const fields = Object.entries(animated as Record); + const transform = (animated as { transform?: unknown }).transform; + // Not a list: no shared value in it. (A string in its place is none either.) + // Stryker disable next-line ArrayDeclaration + const functions = Array.isArray(transform) ? transform.flatMap((step) => Object.values(Object(step))) : []; + return ( + fields.some(([key, value]) => CSS_MOTION.test(key) || sharedValueLike(value)) || functions.some(sharedValueLike) + ); + } + // Not readable: it may well move the view (a read that throws is read as unbounded, too). + // Stryker disable next-line OptionalChaining,ConditionalExpression,LogicalOperator -- so these are equivalent + const initial = animated.initial?.value; + // Stryker disable next-line ConditionalExpression,LogicalOperator -- as above + return initial == null || typeof initial !== 'object' || 'transform' in initial; + }); +} + +/** + * The native-driven transforms above the view, nearest first, or `null` + * when nothing above it is driven natively. Reads the props the last + * `isHidden` resolved as committed. + */ +export function nativeMotion(ancestry: Ancestry): NativeMotion | null { + if (ancestry.animated.length === 0) { + return null; + } + const fibers = ancestry.resolved ?? ancestry.path; + // A ScrollView an event was attached to by its tag is looked for in the view's own tree. + searchRoot = ancestry.root; + const levels: MotionLevel[] = []; + let running = false; + const roots: AnimatedNodeLike[] = []; + // Where in the path a level the shadow tree scales up is, to unbound the levels inside it. + const scaledAt: number[] = []; + for (const index of ancestry.animated) { + let level: Level | Pending | null; + // Props and nodes are the app's: a getter on one can throw. + try { + level = levelOf(ancestry, fibers, index); + } catch { + level = UNBOUNDED_LEVEL; + } + if (level === null) { + continue; + } + running ||= level.running; + roots.push(...level.roots); + if (level.move === undefined) { + continue; + } + if (level.scaled) { + scaledAt.push(index); + } + const { move } = level; + levels.push({ + index, + // An empty catch returns undefined all the same. + // Stryker disable all + move: (rect) => { + try { + return move(rect); + } catch { + return undefined; + } + }, + // Stryker restore all + }); + } + if (levels.length === 0 && roots.length === 0 && !running) { + return null; + } + // A level inside a scale up (a level's, or any host's committed one) is bounded in units that scale + // does not apply to. + const outermost = Math.max(-1, ...scaledAt, scaledHostAbove(fibers, levels[0]?.index)); + return { + levels: levels.map((level) => (level.index < outermost ? { index: level.index, move: () => undefined } : level)), + running, + roots, + }; +} + +/** Whether a host's committed transform has a scale (its value now) for which `scales` holds. */ +function scalesBy(props: unknown, scales: (by: unknown) => boolean): boolean { + return (transformOf(props) ?? []).some( + ([name, value]) => SCALE_AXES[name] !== undefined && scales(isNode(value) ? value.__getValue() : value), + ); +} + +/** Whether a host's committed transform scales by more than 1 (or less than -1), or by what cannot be read. */ +function hostScalesUp(props: unknown): boolean { + return scalesBy(props, (by) => !(Math.abs(by as number) <= 1)); +} + +/** The outermost host above `index` in the path whose committed transform scales up; -1 when none. */ +function scaledHostAbove(fibers: readonly FiberLike[], index: number | undefined): number { + if (index === undefined) { + return -1; + } + for (let above = fibers.length - 1; above > index; above -= 1) { + const fiber = fibers[above] as FiberLike; + if (fiber.tag === FiberTag.HostComponent && hostScalesUp(fiber.memoizedProps)) { + return above; + } + } + return -1; +} + + +/** + * Where the view, measured at `self`, can be drawn (`drawn`, before any + * clipping) and what of that is on screen (`visible`): each native transform + * level, nearest first, moves what the clippers below it left, and the + * clippers above it cut that. A level that cannot be bounded draws it anywhere + * on its surface. + */ +export function throughMotion( + self: WindowRect, + ancestry: Ancestry, + motion: NativeMotion, +): { drawn: WindowRect; visible: WindowRect | null } { + let drawn = self; + let visible: WindowRect | null = self; + let above = -1; + for (const level of motion.levels) { + drawn = level.move(drawn) ?? WHOLE_SURFACE; + if (visible !== null) { + visible = clipToAncestors(visible, ancestry, above, level.index); + } + if (visible !== null) { + visible = level.move(visible) ?? WHOLE_SURFACE; + } + above = level.index; + } + return { drawn, visible: visible === null ? null : clipToAncestors(visible, ancestry, above) }; +} diff --git a/packages/react-native/src/secure/tracker.ts b/packages/react-native/src/secure/tracker.ts index e435fd72..529b9561 100644 --- a/packages/react-native/src/secure/tracker.ts +++ b/packages/react-native/src/secure/tracker.ts @@ -1,9 +1,30 @@ 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 { addFrameMeasurer, addMeasurer, addParked, watchNode, watchValue } from './measureLoop'; +import { whenMoved } from './drivers'; +import { nativeMotion, throughMotion } from './nativeAnimation'; import { clearOwner, setOwnerRectangles } from './registry'; import { surfaceOfInstance } from './surface'; +import { + SECURE_TRANSITION_HOLD_MS, + bounding, + carriedCover, + carriedCovers, + carrierEntering, + carrierLeaving, + carriersOf, + joinStack, + keyOf, + modalOf, + observeStack, + screenCover, + screenRect, + screensOn, + stackMotionOf, + stackMoving, +} from './transition'; +import type { Carrier, StackChange } from './transition'; import { errorName } from '../errorName'; import type { FiberLike, WindowRect } from '../viewtree/fiber'; @@ -15,13 +36,16 @@ type MeasureInWindow = (callback: (x: number, y: number, width: number, height: /** * 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. + * - `active`: moving, or just shown, clipped or carried in by a transition, + * or under an `Animated` component; re-measured every frame until its + * rectangle has stayed put for {@link STEADY_FRAMES} frames (under an + * `Animated` component, always: a native animation can start, or its value + * jump, on any frame, and only its nodes tell). * - `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. + * screens hide it (once per screen a frame); every tick re-checks it 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. @@ -45,8 +69,12 @@ function where(rect: WindowRect | null): string { /** * 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. + * unmount (`depart`) or disable (`stop`). Coverage is added at once and + * removed late (see `SECURE_SHRINK_HOLD_MS`), unmounting included: native + * draws the view until it mounts the commit that removed it, and a + * transition that carries it out draws it longer (see + * `SECURE_TRANSITION_HOLD_MS`). Only `stop`, an explicit disable, removes it + * at once. */ export class SecureTracker { private running = false; @@ -56,6 +84,37 @@ export class SecureTracker { private surface: number | null = null; private ancestry: Ancestry | null = null; private readonly hold = new CoverageHold(); + /** The `` the view is in (`null`: none), read once a mount; `undefined` until then. */ + private modal: Carrier | null | undefined; + /** Its `` was last seen committed as not visible: closing, and already covered. */ + // Stryker disable next-line BooleanLiteral -- `start` clears it before anything reads it + private closing = false; + /** A transition carrying the view in, until `until`. */ + private carried: { readonly carriers: readonly Carrier[]; readonly until: number } | null = null; + /** Where a transition can carry the view, published with the rest until `until`. */ + private cover: { readonly rect: WindowRect; readonly until: number } | null = null; + /** Mounted by the commit now running: its first measurement looks for a transition carrying it in. */ + // Stryker disable next-line BooleanLiteral -- `start` sets it before anything reads it + private arriving = false; + /** Carried in by a transition: re-measured every frame until it settles, whatever mode it was in. */ + // Stryker disable next-line BooleanLiteral -- `start` clears it before anything reads it + private settling = false; + /** Under an `Animated` component: re-measured every frame. Set by every measurement. */ + // Stryker disable next-line BooleanLiteral -- every measurement sets it before `next` reads it + private animating = false; + /** What of the view was on screen at its last measurement (`null`: nothing); `undefined`: never measured. */ + private seen: WindowRect | null | undefined; + /** The native values its transforms come from, watched for an animation starting. */ + // Stryker disable next-line ArrayDeclaration -- the first watch replaces it whatever it holds + private values: object[] = []; + /** Whether each of `values` may be native, as last watched. */ + // Stryker disable next-line ArrayDeclaration -- the first watch replaces it whatever it holds + private natives: boolean[] = []; + private unwatchValues: Array<() => void> = []; + /** Leaves the stacks its screens are on. */ + private leaveStacks: Array<() => void> = []; + /** The screens on each stack its screens are on, by the stack's place in the path, as last seen. */ + private stackScreens = new Map>(); /** Frames (or checks) in a row the visible rectangle stayed put. */ private steady = 0; private lastVisible: string | undefined; @@ -63,6 +122,7 @@ export class SecureTracker { // Stryker disable next-line ArrayDeclaration -- the first watch replaces it whatever it holds private watched: FiberLike[] = []; private unwatch: Array<() => void> = []; + private unwatchModal: (() => void) | undefined; private removeTick: (() => void) | undefined; private removeFrame: (() => void) | undefined; private removeParked: (() => void) | undefined; @@ -73,25 +133,148 @@ export class SecureTracker { ) {} start(): void { + // A remount of the same instance (StrictMode) ends what its unmount left. + this.forget(); this.running = true; + this.arriving = true; this.measure(); this.removeTick = addMeasurer(() => this.onTick()); } + /** Disabled: nothing is covered from now on, at once. */ stop(): void { + this.forget(); + clearOwner(this.token); + } + + /** + * Unmounted: covers what it covered, and where a transition carrying it out + * can still draw it, until that has been held long enough; then removes + * it. Called from the unmounting commit, so its deletions can be read. + */ + depart(): void { + if (!this.running) { + return; + } + const now = monotonicNow(); + const above = this.ancestry; + let cover: WindowRect | null = null; + // The fiber tree and props are read as they are: a getter can throw. + this.attempt(() => { + if (above !== null) { + const leaving = carriersOf(above).filter((carrier) => carrierLeaving(above, carrier)); + cover = carriedCovers(above, leaving, this.seen); + const replaced = this.replacedBy(above, leaving); + if (replaced !== null) { + cover = bounding(replaced, cover); + } + this.tellStacks(above, leaving, now + SECURE_TRANSITION_HOLD_MS, true); + } + }); + this.leave(); + if (cover !== null) { + // Joined with a cover still running (a push it was carried in by), the later end kept. + // Only drops a cover run out: what it returns is not used. + // Stryker disable next-line ArrayDeclaration + this.covering([], now); + const until = Math.max(now + SECURE_TRANSITION_HOLD_MS, this.cover?.until ?? 0); + this.cover = { rect: bounding(cover, this.cover?.rect), until }; + } + const rectangles = this.covering(this.hold.update(null, now), now); + if (rectangles.length === 0) { + clearOwner(this.token); + return; + } + this.publish(rectangles); + this.removeTick = addMeasurer(() => this.onDeparted()); + } + + /** + * A screen popped as another is pushed onto its stack in the same commit + * (a `replace`) is animated out by the incoming screen's animation, not its + * own: where that can draw the view, or `null` when no screen came. + */ + private replacedBy(above: Ancestry, leaving: readonly Carrier[]): WindowRect | null { + let cover: WindowRect | null = null; + for (const carrier of leaving) { + // A Modal (no stack), or a stack never read: nothing to compare with. + const before = this.stackScreens.get(carrier.stack as number); + if (before === undefined) { + continue; + } + const stack = above.path[carrier.stack as number] as FiberLike; + // The commit's own stack fiber may be either of the pair. + const now = [stack, stack.alternate].flatMap((fiber) => (fiber == null ? [] : [...screensOn(fiber)])); + // Either fiber of the pair measures the same host. + const screen = screenRect(above.path[carrier.index] as FiberLike); + for (const [key, props] of now) { + if (!before.has(key)) { + // As the screen below a push: a slide up leaves it where it is; anything not known, the whole screen. + // Its stack was read, so it was measured. + const moved = screenCover(stackMotionOf(props, false), screen, this.seen as WindowRect | null, false); + // Null only for a view seen nowhere, and then for every screen: the cover stays null either way. + // Stryker disable next-line ConditionalExpression + if (moved !== null) { + cover = bounding(moved, cover); + } + } + } + } + return cover; + } + + /** Leaves every loop and forgets everything, without publishing. */ + private forget(): void { + this.leave(); + this.surface = null; + this.ancestry = null; + this.hold.clear(); + this.cover = null; + this.carried = null; + this.modal = undefined; + this.closing = false; + this.seen = undefined; + } + + /** Stops measuring: off every loop, as if never started. */ + private leave(): void { this.running = false; + this.settling = 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.unwatchModal?.(); + this.unwatchModal = undefined; + this.watchValues([]); + // Off the stacks: a stopped view ignores what it is told anyway, so this only frees it. + // Stryker disable next-line all -- so its mutants are equivalent + this.leaveStacks.forEach((leave) => leave()); + this.leaveStacks = []; this.steady = 0; this.lastVisible = undefined; this.lastSelf = undefined; - clearOwner(this.token); + } + + /** The tick after unmounting: publishes what is still held, and removes it all once nothing is. */ + private onDeparted(): void { + const rectangles = this.covering(this.hold.publishable(monotonicNow()), monotonicNow()); + if (rectangles.length === 0) { + this.forget(); + clearOwner(this.token); + } else { + this.publish(rectangles); + } + } + + /** `rectangles` and, while it lasts, the transition's cover. */ + private covering(rectangles: WindowRect[], now: number): WindowRect[] { + // Stryker disable next-line EqualityOperator -- one millisecond either way, at the end of a second + if (this.cover !== null && this.cover.until <= now) { + this.cover = null; + } + return this.cover === null ? rectangles : [this.cover.rect, ...rectangles]; } /** Measures the view and its clipping ancestors, and publishes what it covers. */ @@ -101,15 +284,33 @@ export class SecureTracker { return; } try { - this.surface ??= surfaceOfInstance(view); + // The ancestry first: a view popped before its first measurement answers still knows its screen. this.ancestry ??= ancestryOfInstance(view); + this.surface ??= surfaceOfInstance(view); const above = this.ancestry; + if (this.modal === undefined) { + this.watchModal(above); + this.joinStacks(above); + } if (isHidden(above)) { - this.publish(this.hold.update(null, monotonicNow())); + // Not drawn while hidden (seen nowhere: no stack change covers anything): whatever carries it in is + // looked for once it is shown, and its stacks are read, so a pop that uncovers it is seen, whoever else + // sees it. + this.seen = null; + this.noteBirths(above); + this.attempt(() => this.checkStacks(above, monotonicNow())); + this.publish(this.covering(this.hold.update(null, monotonicNow()), monotonicNow())); this.watch([]); this.enter('hidden'); return; } + // Shown again: perhaps by a transition still running. (Looking again on any other + // measurement finds the same transitions: only a cost.) + // Stryker disable all -- so its mutants are equivalent + if (this.mode === 'hidden') { + this.arriving = true; + } + // Stryker restore all // 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. @@ -135,24 +336,272 @@ export class SecureTracker { 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; + const now = monotonicNow(); + // Where native-driven transforms can draw it, which the shadow tree never sees. + const motion = nativeMotion(above); + const { drawn, visible } = + motion === null ? { drawn: self, visible: clipToAncestors(self, above) } : throughMotion(self, above, motion); + this.seen = visible; + // Read the stacks once seen: a cover for the screens moving above or below goes out with this publish. + // Warned about once a view: the same unreadable tree warns from wherever it is read first. + // Stryker disable all + try { + this.checkStacks(above, now); + } catch (error) { + this.failed(error); + } + // Stryker restore all + // Per frame while a native animation runs; otherwise woken the frame one starts. + this.animating = motion?.running ?? false; + this.watchValues(motion?.roots ?? []); + // Stryker disable next-line ConditionalExpression -- as when shown again: arriving again only costs + if (this.arriving) { + this.arrive(above, now); + } + if (this.carried !== null) { + this.carry(visible, above, now); + } + if (this.modal) { + this.watchClosing(visible, above, now); + } + this.publish(this.covering(this.hold.update(visible, now), now)); + const clipped = visible === null || visible.width < drawn.width || visible.height < drawn.height; + const at = where(visible); + this.steady = at === this.lastVisible ? this.steady + 1 : 0; + this.lastVisible = at; this.watch(clipped ? clipWatchNodes(above) : []); this.enter(this.next(clipped)); } + /** Mounted: is a transition carrying it in? Then it is followed every frame until it settles. */ + private arrive(above: Ancestry, now: number): void { + // Stryker disable next-line BooleanLiteral -- as above + this.arriving = false; + // As in `depart`: a read that throws carries nothing, and the view is still published. + this.attempt(() => { + let until = 0; + const carriers = carriersOf(above).filter((carrier) => { + const ends = carrierEntering(above, carrier, now); + until = Math.max(until, ends ?? 0); + return ends !== undefined; + }); + if (carriers.length > 0) { + this.carried = { carriers, until }; + // Read by its own view before and after (as it is carried in): telling it leaving changes nothing it keeps. + // Stryker disable next-line BooleanLiteral + this.tellStacks(above, carriers, until, false); + // A Modal's content is laid out again once its dialog is up: within a frame. + this.settling = true; + } + }); + } + + /** + * Finds the `` the view is in, hidden or not, and watches its host's + * props, which change the commit that hides it: woken within a frame. + */ + private watchModal(above: Ancestry): void { + this.modal = null; + try { + this.modal = modalOf(above); + } catch { + // A tree that cannot be read: no Modal to watch (the measurement says why, once). + } + if (this.modal) { + const host = above.path[this.modal.index] as FiberLike; + // Stryker disable next-line StringLiteral -- woken in any mode but tick or parked, it is measured that frame + this.unwatchModal = watchNode(host, () => this.enter('active'), false); + } + } + + /** + * A `` set `visible={false}` and still mounted (iOS keeps it until + * `onDismiss`) slides out with the view in it: from the commit that hides + * it, covers the band below the view for the transition hold, as an + * unmount does where the Modal is removed at once (Android). + */ + private watchClosing(self: WindowRect | null, above: Ancestry, now: number): void { + const carrier = this.modal as Carrier; + const host = (above.resolved ?? above.path)[carrier.index]; + const hidden = (host?.memoizedProps as { visible?: unknown } | null | undefined)?.visible === false; + if (hidden && !this.closing) { + const cover = carriedCover(above, carrier, self); + if (cover !== null) { + // A later deadline already set (a neighbour's push) is kept: the later transition still runs. + const until = Math.max(now + SECURE_TRANSITION_HOLD_MS, this.cover?.until ?? 0); + this.cover = { rect: bounding(cover, this.cover?.rect), until }; + } + } + this.closing = hidden; + } + + /** + * While a transition carries it in: covers where that can draw it from + * where it rests now, as well as from where it rested before (a Modal's + * content laid out again moves the band), until the transition hold ends. + */ + private carry(self: WindowRect | null, above: Ancestry, now: number): void { + const { carriers, until } = this.carried as { carriers: readonly Carrier[]; until: number }; + // Past it, the cover set below would be dropped as soon as it is published. + // Stryker disable next-line ConditionalExpression,BlockStatement,EqualityOperator -- so carrying on changes nothing + if (until <= now) { + this.carried = null; + return; + } + // Not laid out yet (0x0), or cut away: drawn nowhere, so nothing to cover from it. + if (self === null || self.width <= 0 || self.height <= 0) { + return; + } + let cover: WindowRect | null; + try { + cover = carriedCovers(above, carriers, self); + } catch (error) { + // Warned once, as in `measured`. + // Stryker disable next-line all + this.failed(error); + return; + } + if (cover !== null) { + // A later deadline (a neighbour's push or pop, a close) is kept: that transition still runs. + this.cover = { rect: bounding(cover, this.cover?.rect), until: Math.max(until, this.cover?.until ?? 0) }; + } + } + + /** Records the transitions creating its carriers, for when it is shown while they run. */ + private noteBirths(above: Ancestry): void { + try { + carriersOf(above).forEach((carrier) => carrierEntering(above, carrier, monotonicNow())); + } catch { + // Unreadable: nothing recorded; the measurement says why, once. + } + } + + /** Joins the stacks its screens are on, to be told when another screen on one moves. */ + private joinStacks(above: Ancestry): void { + try { + for (const carrier of carriersOf(above)) { + // Only the Modal, last, has no stack: failing on it, after the screens, joins them all the same. + // Stryker disable next-line ConditionalExpression -- so its mutant is equivalent + if (carrier.stack !== undefined) { + const stack = keyOf(above.path[carrier.stack] as FiberLike); + const told = (change: StackChange): void => this.screenMoved(carrier, change); + this.leaveStacks.push(joinStack(stack, told)); + // A commit that pushes or pops a screen re-renders the stack: woken within a frame. + // Measuring the stack as well only wakes it more often; any mode but tick or parked measures that frame. + // Stryker disable next-line StringLiteral,BooleanLiteral + this.leaveStacks.push(watchNode(above.path[carrier.stack] as FiberLike, () => this.enter('active'), false)); + } + } + } catch { + // Unreadable: no stacks joined; the measurement says why, once. + } + } + + /** Tells the stacks of the screens in `carriers` that those screens are popped (`leaving`) or pushed. */ + private tellStacks(above: Ancestry, carriers: readonly Carrier[], until: number, leaving: boolean): void { + const fibers = above.resolved ?? above.path; + for (const carrier of carriers) { + if (carrier.stack !== undefined) { + const screen = fibers[carrier.index] as FiberLike; + const stack = keyOf(above.path[carrier.stack] as FiberLike); + const motion = stackMotionOf(screen.memoizedProps, false); + stackMoving(stack, { motion, until, moving: keyOf(screen), below: undefined }, leaving); + } + } + } + + /** + * Reads the stacks its screens are on: a screen pushed onto, or popped + * off, one (with or without a `` of its own) since any view + * last read it is recorded for it. Seen on screen, it covers every change + * still in its hold that moves its own screen, uncovered or covered, from + * where it is now: also one made before it mounted, or while it was hidden. + */ + private checkStacks(above: Ancestry, now: number): void { + const fibers = above.resolved ?? above.path; + for (const carrier of carriersOf(above)) { + if (carrier.stack === undefined) { + continue; + } + const stack = fibers[carrier.stack] as FiberLike; + const screens = screensOn(stack); + this.stackScreens.set(carrier.stack, screens); + observeStack(stack, screens, now).forEach((change) => this.screenMoved(carrier, change)); + } + } + + /** + * Another screen on a stack its screen is on is pushed or popped: its own + * screen, uncovered or covered, moves too, as `change.motion` until + * `change.until`. Covered from where it is seen now, joined with where it + * was: every measurement in the hold follows it. + */ + private screenMoved(carrier: Carrier, change: StackChange): void { + const above = this.ancestry as Ancestry; + const screen = above.path[carrier.index] as FiberLike; + const key = keyOf(screen); + // A stopped view has left its stacks; a view on the moving screen is carried, over as much (each motion + // covers the screen moving at least as much as the one below). + // Stryker disable next-line ConditionalExpression,LogicalOperator,BlockStatement -- so these tests only spare work + if (!this.running || change.moving === key) { + return; + } + // Seen nowhere, nothing to cover; a change that uncovers or covers another screen does not move its own. + if (this.seen == null || (change.below !== undefined && change.below !== key)) { + return; + } + // Seen on screen, so covered somewhere. + const cover = screenCover(change.motion, screenRect(screen), this.seen, false) as WindowRect; + // Whatever cover is still running is joined, not replaced; one run out is dropped first. + // A run-out cover is dropped on the next publish anyway. + // Stryker disable next-line all -- so these are equivalent + this.covering([], monotonicNow()); + const kept = this.cover; + this.cover = { rect: bounding(cover, kept?.rect), until: Math.max(change.until, kept?.until ?? 0) }; + this.republish(); + } + + /** + * Watches `nodes` (the values its transforms come from): measured at once + * when JS moves one (where that is followed), and, for a native one, woken + * the frame its fields show a change native made (an animation ending). + */ + private watchValues(nodes: readonly object[]): void { + const natives = nodes.map((node) => (node as { __isNative?: unknown }).__isNative !== false); + // Watching the same values again takes their state now as the baseline, as the frame just did: only work. A + // value made native since is polled from then on; JS moving it is followed, so that only adds a safety net. + // Stryker disable next-line ArrowFunction -- as above + const same = (node: object, index: number): boolean => + // Stryker disable next-line ConditionalExpression,LogicalOperator,EqualityOperator -- as above + node === this.values[index] && natives[index] === this.natives[index]; + // Stryker disable next-line ConditionalExpression,MethodExpression,EqualityOperator,BlockStatement -- as above + if (nodes.length === this.values.length && nodes.every(same)) { + return; + } + this.unwatchValues.forEach((stop) => stop()); + this.values = [...nodes]; + this.natives = natives; + // Stryker disable next-line StringLiteral -- woken in any mode but tick or parked, it is measured that frame + const wake = (): void => this.enter('active'); + // Measured now: it goes active if it finds an animation running. + const now = (): void => this.measure(); + this.unwatchValues = this.values.flatMap((node, index) => { + const told = whenMoved(node, now); + // Driven from JS, it keeps no frame loop where JS moving it is followed. + return told === undefined ? [watchValue(node, wake)] : natives[index] ? [told, watchValue(node, wake)] : [told]; + }); + } + private next(clipped: boolean): Mode { if (this.mode === 'active') { - if (this.steady < STEADY_FRAMES) { + if (this.steady < STEADY_FRAMES || this.animating || this.carried !== null) { return 'active'; } + this.settling = false; return clipped ? 'parked' : 'tick'; } - if (this.mode === 'hidden') { + // Carried in is also settling: per frame. + if (this.mode === 'hidden' || this.settling || this.animating) { return 'active'; } if (!clipped) { @@ -164,11 +613,21 @@ export class SecureTracker { private onTick(): void { if (this.mode === 'tick') { this.measure(); - } else if (this.mode === 'parked' && this.hold.holding()) { - this.publish(this.hold.publishable(monotonicNow())); + } else if (this.mode === 'parked' && this.changing()) { + this.republish(); } } + /** Whether what is published will change on its own: something held, or a cover, runs out. */ + private changing(): boolean { + return this.hold.holding() || this.cover !== null; + } + + private republish(): void { + const now = monotonicNow(); + this.publish(this.covering(this.hold.publishable(now), now)); + } + private onFrame(): void { if (this.mode === 'active') { this.measure(); @@ -185,8 +644,8 @@ export class SecureTracker { // 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())); + } else if (this.changing()) { + this.republish(); } } @@ -246,6 +705,15 @@ export class SecureTracker { setOwnerRectangles(this.token, 0, rectangles, this.surface as number); } + /** Runs `run`; what it throws is warned about, once a view, and stops nothing else. */ + private attempt(run: () => void): void { + try { + run(); + } catch (error) { + this.failed(error); + } + } + private failed(error: unknown): void { if (!this.warned) { this.warned = true; diff --git a/packages/react-native/src/secure/transition.ts b/packages/react-native/src/secure/transition.ts new file mode 100644 index 00000000..eca0f55b --- /dev/null +++ b/packages/react-native/src/secure/transition.ts @@ -0,0 +1,488 @@ +import { MODAL_SCREEN_HOST_TYPE, SCREEN_HOST_TYPE, measureFiber, passMemo } from './ancestry'; +import type { Ancestry } from './ancestry'; +import { MODAL_HOST_TYPE } from './surface'; +import { FiberTag } from '../viewtree/fiber'; +import type { FiberLike, WindowRect } from '../viewtree/fiber'; + +/** + * Native transitions carry a `` where its measurements cannot + * follow, and draw it after React is done with it. + * + * A react-native-screens stack animates a pushed screen in, and a popped one + * out, natively: React mounts the new screen, or unmounts the popped one, at + * the start, and the screen is then drawn moving (Android 13+: 10% sideways + * over 450 ms; before that a 0.85 to 1 scale about its centre; iOS: the whole + * width) for as long as the animation runs. A ``'s dialog slides in + * from the bottom of the display and out again the same way, with its content + * laid out where it ends. None of this is in the shadow tree, so + * `measureInWindow` reads where the view rests, and a popped screen's views + * are not measurable at all. + * + * So for {@link SECURE_TRANSITION_HOLD_MS} from the commit that starts such a + * transition, a view it carries also covers everywhere the transition can + * draw it: + * - a screen pushed onto, or popped off, a stack, and the screen it uncovers + * or covers on that stack (which moves too): by the screen's + * `stackAnimation`, the band of the view across the screen for a + * sideways slide, the column from the view down for a slide from the + * bottom, the view itself for a fade or none; for the platform default and + * anything else, the screen's whole rectangle. The stack clips what its + * screens draw to its bounds, so each of these holds for its animation in + * either direction and RTL, and the whole screen holds for any; + * - a `` opening or closing: the band from the + * view's top down to the bottom of the surface, the path it slides along. + * A fading Modal does not move the view, and the hold every unmount gets + * keeps it covered where it rests while the dialog fades. + * + * A transition is told apart from a view coming and going inside a screen + * that stays by the fiber tree alone: in the commit that mounts a view, an + * ancestor created by that same commit has no alternate yet (and the time is + * kept, so a view mounted, or shown, later in the same transition is covered + * too); in the commit that unmounts it, the root of what is deleted is listed + * in its parent's `deletions`. + * + * 800 ms from the commit: on an API 30 emulator (Debug) the native animation + * started ~200 ms after the commit and react-native-screens' default Android + * animations run 450 ms (200 ms before Android 13), 650 ms in all; iOS pushes + * run ~350-500 ms. React Native screens report the end of a transition only + * to the navigator's own handlers, so the cover ends on time. + */ +export const SECURE_TRANSITION_HOLD_MS = 800; + +/** + * A rectangle past every edge of any surface, for a cover whose bounds cannot + * be measured: both SDKs cut what they mask to the display. + */ +export const WHOLE_SURFACE: WindowRect = { x: 0, y: 0, width: 100_000, height: 100_000 }; + +/** The react-native-screens stacks, which animate screens in and out natively. */ +export const STACK_HOST_TYPES: ReadonlySet = new Set(['RNSScreenStack']); + +/** What carries a view through a transition: its nearest stack screen, or its ``. */ +export interface Carrier { + readonly kind: 'screen' | 'modal'; + /** Where the screen's, or the Modal's, host is in `Ancestry.path`. */ + readonly index: number; + /** A screen's stack, in `Ancestry.path`. */ + readonly stack?: number; +} + +/** + * What can carry the view through a native transition: every screen in a + * stack above it (nearest first), and the `` it is in, each + * independently (a stack inside a Modal slides in with the Modal, and pushes + * within it on its own; in nested stacks, the screen that moves is the one + * pushed or popped). + */ +export function carriersOf(ancestry: Ancestry): Carrier[] { + // `hosts` stop at a Modal (a root of its own): the screens it was opened over move without it. + const carriers: Carrier[] = ancestry.hosts + .filter((host) => host.screen && STACK_HOST_TYPES.has(ancestry.path[host.above]?.type)) + .map((host) => ({ kind: 'screen' as const, index: host.index, stack: host.above })); + const modal = modalOf(ancestry); + if (modal !== null) { + carriers.push(modal); + } + return carriers; +} + +/** + * Where the `carriers` can draw a view seen at `view`, joined, or + * `null` when none of them moves it. + */ +export function carriedCovers( + ancestry: Ancestry, + carriers: readonly Carrier[], + view: WindowRect | null | undefined, +): WindowRect | null { + let joined: WindowRect | null = null; + for (const carrier of carriers) { + const cover = carriedCover(ancestry, carrier, view); + if (cover !== null) { + joined = bounding(cover, joined); + } + } + return joined; +} + +/** The bounding box of `a` and `b` (when there is one). */ +export function bounding(a: WindowRect, b: WindowRect | null | undefined): WindowRect { + if (b == null) { + return a; + } + const left = Math.min(a.x, b.x); + const top = Math.min(a.y, b.y); + const right = Math.max(a.x + a.width, b.x + b.width); + const bottom = Math.max(a.y + a.height, b.y + b.height); + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +/** The `` the view is in, or `null`. */ +export function modalOf(ancestry: Ancestry): Carrier | null { + const index = ancestry.path.findIndex( + // Only a host has a string type. + (fiber) => fiber.type === MODAL_HOST_TYPE, + ); + return index < 0 ? null : { kind: 'modal', index }; +} + +/** + * Until when the carrier is being carried in (`undefined`: it is not): it + * was pushed, or opened, no longer ago than {@link SECURE_TRANSITION_HOLD_MS}. + * Its birth is kept for every view that comes later, by the carrier: + * - a carrier with no alternate was created by the commit running now (a + * fiber gets its alternate the first time it is rendered again, and every + * ancestor of a view that mounts is created or rendered again by that + * commit), as a push or a Modal opening does. A tree mounted whole, as an + * app starts, is drawn where it is laid out, with no transition: something + * above the carrier must have been there before (the stack, the screen + * that opened the Modal); + * - a screen another view saw pushed onto its stack ({@link noteBirth}); + * - otherwise it was there before, first seen now: when it came is not + * known (a screen pushed with a spinner, its secure content mounting a + * commit later), so it is taken as coming now. Once per carrier. + */ +export function carrierEntering(ancestry: Ancestry, carrier: Carrier, now: number): number | undefined { + const { path } = ancestry; + const fiber = path[carrier.index] as FiberLike; + const key = keyOf(fiber); + if (!births.has(key)) { + const mountedWhole = + fiber.alternate == null && + // Nothing below a created carrier can have been there before it. + // Stryker disable next-line MethodExpression -- so looking from the bottom finds the same + !path.slice(carrier.index + 1).some((above) => above.alternate != null); + births.set(key, mountedWhole ? -Infinity : now); + } + const born = births.get(key) as number; + return now - born < SECURE_TRANSITION_HOLD_MS ? born + SECURE_TRANSITION_HOLD_MS : undefined; +} + +/** + * When each carrier came, by {@link keyOf}: a view that mounts, or is shown, + * while it is still being pushed or opened is carried in with it. + * `-Infinity`: mounted with the tree it is in, as an app starts. + */ +const births = new WeakMap(); + +/** A screen (by {@link keyOf}) seen pushed onto a stack at `now`, unless its birth is known already. */ +export function noteBirth(key: object, now: number): void { + if (!births.has(key)) { + births.set(key, now); + } +} + +/** + * What stays the same for a host across both fibers of its pair and every + * commit: Fabric's shared `canonical` (a host's `stateNode` is cloned on + * every update); on Paper the `stateNode` itself, which both fibers share; + * the fiber when there is neither. + */ +export function keyOf(fiber: FiberLike): object { + const node = fiber.stateNode as { canonical?: unknown } | null | undefined; + const canonical = node?.canonical; + if (canonical !== null && typeof canonical === 'object') { + return canonical; + } + return node !== null && typeof node === 'object' ? node : fiber; +} + +function deletes(parent: FiberLike | null | undefined, child: FiberLike): boolean { + const deletions = (parent as { deletions?: unknown } | null | undefined)?.deletions; + return Array.isArray(deletions) && (deletions.includes(child) || deletions.includes(child.alternate)); +} + +/** + * Where in `Ancestry.path` the root of what the running commit deletes is + * (-1: the view's own host), or `undefined` when no fiber on the way up is + * being deleted. Only meaningful while that commit unmounts the view. + */ +export function deletionRoot(ancestry: Ancestry): number | undefined { + const { self, path } = ancestry; + // Only an ancestry with no fiber has no self, and it has no path either. + let child = self as FiberLike; + for (let index = -1; index < path.length - 1; index += 1) { + const parent = path[index + 1] as FiberLike; + // Either fiber of the parent's pair may be the one this commit finished. + if (deletes(parent, child) || deletes(parent.alternate, child)) { + return index; + } + child = parent; + } + return undefined; +} + +/** Whether the running commit deletes the carrier itself, or something above it. */ +export function carrierLeaving(ancestry: Ancestry, carrier: Carrier): boolean { + // Nothing deleted (`undefined`) is never at or above anything. + return Number(deletionRoot(ancestry)) >= carrier.index; +} + +/** How a stack animation moves its screens, as far as covering goes. */ +export type StackMotion = 'sideways' | 'upwards' | 'still' | 'any'; + +const SIDEWAYS: ReadonlySet = new Set([ + 'slide_from_right', + 'slide_from_left', + 'ios_from_right', + 'ios_from_left', + 'simple_push', +]); +const UPWARDS: ReadonlySet = new Set(['slide_from_bottom', 'fade_from_bottom']); +const STILL: ReadonlySet = new Set(['fade', 'none']); +/** iOS presentations that slide a screen up over the one below by default. */ +const MODAL_PRESENTATIONS: ReadonlySet = new Set([ + 'modal', + 'fullScreenModal', + 'formSheet', + 'pageSheet', + 'transparentModal', + 'containedModal', + 'containedTransparentModal', +]); + +// A missing Platform throws, and is caught; an empty catch returns undefined all the same. +// Stryker disable all +export function platformOS(): unknown { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports -- lazy: only an app has it + return (require('react-native') as { Platform?: { OS?: unknown } }).Platform?.OS; + } catch { + return undefined; + } +} +// Stryker restore all + +/** + * How the screen whose host props are `props` is animated in and out: from + * its `stackAnimation`, and on iOS its presentation. The platform default + * (sideways on Android 13+, a scale before, sideways on iOS) and anything + * unknown is `any`. + */ +export function stackMotionOf(props: unknown, moving = true): StackMotion { + const { stackAnimation: animation, stackPresentation: presentation } = (props ?? {}) as Record; + if (SIDEWAYS.has(animation)) { + return 'sideways'; + } + if (UPWARDS.has(animation)) { + return 'upwards'; + } + if (STILL.has(animation)) { + return 'still'; + } + const defaulted = animation === undefined || animation === 'default'; + if (defaulted && MODAL_PRESENTATIONS.has(presentation) && platformOS() === 'ios') { + // The card slides up; iOS 13+ also scales the screen below it about its centre. + return moving ? 'upwards' : 'any'; + } + return 'any'; +} + +/** react-native-screens' screen hosts, as a stack's children. */ +const SCREEN_TYPES: ReadonlySet = new Set([SCREEN_HOST_TYPE, MODAL_SCREEN_HOST_TYPE]); + +/** How deep below a stack its screens can be (wrappers such as a stack item). */ +const MAX_SCREEN_DEPTH = 16; + +/** + * The screens on `stack` (a committed stack host fiber), by {@link keyOf}, + * with their props: its descendants down to the first screen hosts. + */ +export function screensOn(stack: FiberLike): Map { + // Once a measure pass for every view on the stack. + return passMemo(stack, () => findScreens(stack)); +} + +function findScreens(stack: FiberLike): Map { + const screens = new Map(); + const visit = (fiber: FiberLike | null, depth: number): void => { + for (let child = fiber; child != null; child = child.sibling) { + // Only a host has a screen's (string) type: the tag only spares a lookup. + // Stryker disable next-line ConditionalExpression + if (child.tag === FiberTag.HostComponent && SCREEN_TYPES.has(child.type)) { + screens.set(keyOf(child), child.memoizedProps); + } else if (depth < MAX_SCREEN_DEPTH) { + visit(child.child, depth + 1); + } + } + }; + visit(stack.child, 0); + return screens; +} + +/** + * Where a stack transition moving as `motion` can draw a view on a screen at + * `screen`, seen at `view` (`null`: nothing of it is on screen, cut away by + * a ScrollView, say, and so drawn nowhere as the screen moves either). `moving`: + * the screen itself is the one pushed or popped; otherwise it is the one + * uncovered or covered below, which a slide up leaves where it is. + */ +export function screenCover( + motion: StackMotion, + screen: WindowRect, + view: WindowRect | null, + moving: boolean, +): WindowRect | null { + if (view === null) { + return null; + } + if (motion === 'any') { + return screen; + } + if (motion === 'sideways') { + return { x: screen.x, y: view.y, width: screen.width, height: view.height }; + } + if (motion === 'upwards' && moving) { + const bottom = Math.max(screen.y + screen.height, view.y + view.height); + return { x: view.x, y: view.y, width: view.width, height: bottom - view.y }; + } + return view; +} + +/** The window rectangle of a screen host, or the whole surface when it cannot be measured. */ +export function screenRect(fiber: FiberLike): WindowRect { + let screen: WindowRect | null | undefined; + try { + screen = measureFiber(fiber); + } catch { + // Unmeasurable: covered as the whole surface below. + } + return screen ?? WHOLE_SURFACE; +} + +/** + * Where `carrier` can draw a view seen at `view` while it is carried in or + * out (`view`: what of it is on screen, `null` when nothing is, `undefined` + * when it was never measured), or `null` when it does not move it. + */ +export function carriedCover( + ancestry: Ancestry, + carrier: Carrier, + view: WindowRect | null | undefined, +): WindowRect | null { + const fiber = (ancestry.resolved ?? ancestry.path)[carrier.index] as FiberLike; + if (carrier.kind === 'screen') { + const screen = screenRect(fiber); + return view === undefined ? screen : screenCover(stackMotionOf(fiber.memoizedProps), screen, view, true); + } + const animationType = (fiber.memoizedProps as { animationType?: unknown } | null | undefined)?.animationType; + // Never measured, or nothing of it on screen: it is drawn nowhere it was measured either. + if (view == null) { + return null; + } + if (animationType === 'slide') { + return { x: view.x, y: view.y, width: view.width, height: WHOLE_SURFACE.height - view.y }; + } + return null; +} + +/** + * A screen pushed onto, or popped off, a stack, as the screens left on it + * move with it until `until`. + */ +export interface StackChange { + /** How the screens below it move: the pushed or popped screen's `stackMotionOf(props, false)`. */ + readonly motion: StackMotion; + readonly until: number; + /** The screen pushed or popped, by {@link keyOf}. */ + readonly moving: object; + /** The screen it covers (a push) or uncovers (a pop), by {@link keyOf}; `undefined`: not known. */ + readonly below: object | undefined; +} + +/** A view on a stack's screen, told when another screen on that stack is pushed or popped. */ +export type StackNeighbour = (change: StackChange) => void; + +/** What is known of a stack: kept by the stack, so a view that comes later still learns it. */ +interface StackRecord { + /** Its screens as last read by any view, by {@link keyOf}, with their props; `undefined`: never read. */ + screens: Map | undefined; + /** Its recent changes, oldest first, the ones whose hold is over dropped as it is read. */ + changes: StackChange[]; + /** The views on its screens, told of a change as a view on the moving screen makes it. */ + readonly views: Set; +} + +/** Every stack's record, by the stack's {@link keyOf}. */ +const records = new WeakMap(); + +function recordOf(stack: object): StackRecord { + let record = records.get(stack); + if (record === undefined) { + // Stryker disable next-line ArrayDeclaration -- its changes are filtered before they are read + record = { screens: undefined, changes: [], views: new Set() }; + records.set(stack, record); + } + return record; +} + +/** The changes still within their hold at `now`. */ +function current(record: StackRecord, now: number): StackChange[] { + // Stryker disable next-line EqualityOperator -- one millisecond either way, at the end of a hold + record.changes = record.changes.filter((change) => change.until > now); + return record.changes; +} + +/** Adds `neighbour` to the views on `stack`'s screens, until the returned function is called. */ +export function joinStack(stack: object, neighbour: StackNeighbour): () => void { + const { views } = recordOf(stack); + views.add(neighbour); + return () => { + views.delete(neighbour); + }; +} + +/** + * A view on the screen `change.moving` saw it pushed or popped (`leaving`): + * records it for the stack, and tells every view on it now; a view that + * reads the stack later in the hold learns it then. A popped screen is off + * the stack's screens from now: its views, gone, read the stack no more (a + * pushed one's view reads the stack itself). + */ +export function stackMoving(stack: object, change: StackChange, leaving: boolean): void { + const record = recordOf(stack); + current(record, change.until - SECURE_TRANSITION_HOLD_MS); + record.changes.push(change); + // A pushed screen's own view reads the stack before and after it tells it: dropping it changes nothing kept. + // Stryker disable next-line ConditionalExpression + if (leaving) { + record.screens?.delete(change.moving); + } + for (const neighbour of [...record.views]) { + neighbour(change); + } +} + +/** The last of `screens` (a stack's children in order): the one on top. */ +function topOf(screens: ReadonlyMap): object | undefined { + return [...screens.keys()].pop(); +} + +/** + * Reads `stack` (a committed stack host fiber), whose screens are `screens` + * now, against what any view last read of it: a screen pushed or popped + * since is recorded as moving from `now`, unless it is recorded already. + * Read for the first time, it is compared with the stack's other fiber, + * its render before (a pop no view on it saw may have just happened). + * Returns every change still within its hold. + */ +export function observeStack(stack: FiberLike, screens: Map, now: number): readonly StackChange[] { + const record = recordOf(keyOf(stack)); + const changes = current(record, now); + const before = record.screens ?? (stack.alternate == null ? screens : screensOn(stack.alternate)); + record.screens = screens; + const pushed = [...screens].filter(([key]) => !before.has(key)); + const popped = [...before].filter(([key]) => !screens.has(key)); + // Seen come: a view on it that mounts later in its push is carried in from now. + pushed.forEach(([key]) => noteBirth(key, now)); + const moved = [ + ...pushed.map(([key, props]) => ({ key, props, below: topOf(before) })), + ...popped.map(([key, props]) => ({ key, props, below: topOf(screens) })), + ]; + for (const { key, props, below } of moved) { + if (!changes.some((change) => change.moving === key)) { + changes.push({ motion: stackMotionOf(props, false), until: now + SECURE_TRANSITION_HOLD_MS, moving: key, below }); + } + } + return changes; +} diff --git a/scripts/check-rn-compat.sh b/scripts/check-rn-compat.sh index 38b875f9..de27cc8f 100755 --- a/scripts/check-rn-compat.sh +++ b/scripts/check-rn-compat.sh @@ -289,6 +289,103 @@ grep -qE "uiViewClassName: 'AndroidHorizontalScrollView'" \ || { 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" +# (6) 's transitions and native-driven transforms +# (src/secure/transition.ts, src/secure/nativeAnimation.ts) depend on: +# - a deleted child being listed in its parent's `deletions` (deleteChild); +# - Animated components being named `Animated()`; +# - an Animated.Value keeping its running animation in `_animation`, a timing +# animation its `_fromValue`, `_toValue` and `_easing`, an interpolation its +# `_parent`, `_config` and `_getInterpolation()`, and a native node +# `__isNative`; a value its `_value`, `_offset` and `hasListeners()`; diffClamp, +# modulo and division their own fields; Modal its `animationType` prop; +# - native Animated.events attached through AnimatedEvent.__attach/__detach, +# or attachNativeEvent, and values made native through __makeNative. +for RENDERER_BUNDLE in \ + "$RN_SRC/Libraries/Renderer/implementations/ReactFabric-dev.js" \ + "$RN_SRC/Libraries/Renderer/implementations/ReactFabric-prod.js" +do + grep -qE 'deletions = \[childToDelete\]' "$RENDERER_BUNDLE" \ + || { echo "FAIL: $(basename "$RENDERER_BUNDLE") no longer lists a deleted child in its parent's deletions"; exit 1; } +done +ANIMATED="$RN_SRC/Libraries/Animated" +grep -qE 'Animated\(\$\{' "$ANIMATED/createAnimatedComponent.js" \ + || { echo "FAIL: createAnimatedComponent.js no longer names its components Animated()"; exit 1; } +grep -qE 'this\._animation = animation' "$ANIMATED/nodes/AnimatedValue.js" \ + || { echo "FAIL: AnimatedValue.js no longer keeps its running animation in _animation"; exit 1; } +for FIELD in '_fromValue = fromValue' '_toValue = config\.toValue' '_easing = '; do + grep -qE "this\.$FIELD" "$ANIMATED/animations/TimingAnimation.js" \ + || { echo "FAIL: TimingAnimation.js no longer sets this.$FIELD"; exit 1; } +done +for FIELD in 'this\._parent = parent' 'this\._config = config' '_getInterpolation\(\)'; do + grep -qE "$FIELD" "$ANIMATED/nodes/AnimatedInterpolation.js" \ + || { echo "FAIL: AnimatedInterpolation.js no longer has $FIELD"; exit 1; } +done +grep -qE 'this\.__isNative = true' "$ANIMATED/nodes/AnimatedWithChildren.js" \ + || { echo "FAIL: AnimatedWithChildren.js no longer marks a native node __isNative"; exit 1; } +for FIELD in 'this\._value = ' 'this\._offset = ' 'this\._animation = null' 'hasListeners\(\)'; do + grep -qE "$FIELD" "$ANIMATED/nodes/AnimatedValue.js" "$ANIMATED/nodes/AnimatedNode.js" \ + || { echo "FAIL: AnimatedValue.js/AnimatedNode.js no longer has $FIELD"; exit 1; } +done +grep -qE 'this\._min = min' "$ANIMATED/nodes/AnimatedDiffClamp.js" \ + || { echo "FAIL: AnimatedDiffClamp.js no longer keeps its bounds in _min/_max"; exit 1; } +grep -qE 'this\._modulus = modulus' "$ANIMATED/nodes/AnimatedModulo.js" \ + || { echo "FAIL: AnimatedModulo.js no longer keeps its modulus in _modulus"; exit 1; } +grep -qE '_warnedAboutDivideByZero' "$ANIMATED/nodes/AnimatedDivision.js" \ + || { echo "FAIL: AnimatedDivision.js is no longer told apart by _warnedAboutDivideByZero"; exit 1; } +# A native Animated.event is attached and detached through AnimatedEvent's +# own __attach/__detach (an Animated component's props), its values in +# _argMapping[0], and the module is reachable at its Libraries path. +grep -qE 'export class AnimatedEvent' "$ANIMATED/AnimatedEvent.js" \ + || { echo "FAIL: AnimatedEvent.js no longer exports the AnimatedEvent class"; exit 1; } +for FIELD in '__attach\(viewRef' '__detach\(viewTag' 'this\._argMapping = argMapping' 'argMapping\[0\]\.nativeEvent'; do + grep -qE "$FIELD" "$ANIMATED/AnimatedEvent.js" \ + || { echo "FAIL: AnimatedEvent.js no longer has $FIELD"; exit 1; } +done +# A call with the view and the prop name (nodes' own `super.__attach()` takes none). +grep -rqE '\.__attach\([A-Za-z_]+, *[A-Za-z_]+\)' --exclude=AnimatedEvent.js "$RN_SRC/src/private/animated" "$ANIMATED" \ + || { echo "FAIL: no Animated component attaches its native events with __attach any more"; exit 1; } +# Events attached with attachNativeEvent directly (React Native's ScrollView for +# its sticky headers, or an app through Animated) are followed by wrapping it on +# AnimatedImplementation's default export, which ScrollView calls it on, and on +# the public Animated object, which spreads that export; the handle's detach +# ends the attachment. A value is made native through AnimatedValue's own +# __makeNative (an animation's start, an event's attach), the module's default +# export. +# src/secure/followEvents.ts requires these four by path, each optional to Metro (in a try block): a file +# moved builds and follows nothing (every still native value then masked as moving). Noticed here first. +for MODULE in AnimatedEvent AnimatedImplementation Animated nodes/AnimatedValue; do + [ -f "$ANIMATED/$MODULE.js" ] \ + || { echo "FAIL: Libraries/Animated/$MODULE.js is gone: src/secure/followEvents.ts requires it"; exit 1; } +done +# A vertical ScrollView's content container on Android is the View component's host, below the ScrollView's host +# (nativeAnimation.ts and ancestry.ts look through at most CONTENT_DEPTH components for it). +grep -qE "Platform\.OS === 'android' \? View : ScrollContentViewNativeComponent" \ + "$RN_SRC/src/private/components/scrollview/VScrollViewNativeComponents.js" \ + || { echo "FAIL: VScrollContentViewNativeComponent is no longer View on Android: recheck CONTENT_DEPTH"; exit 1; } +grep -qE '^export default \{' "$ANIMATED/AnimatedImplementation.js" \ + && grep -qE '^ attachNativeEvent: attachNativeEventImpl,' "$ANIMATED/AnimatedImplementation.js" \ + || { echo "FAIL: AnimatedImplementation.js no longer exports attachNativeEvent on its default object"; exit 1; } +grep -qE 'AnimatedImplementation\.attachNativeEvent\(' "$RN_SRC/Libraries/Components/ScrollView/ScrollView.js" \ + || { echo "FAIL: ScrollView.js no longer calls AnimatedImplementation.attachNativeEvent"; exit 1; } +grep -qE '^export function attachNativeEventImpl\(' "$ANIMATED/AnimatedEvent.js" \ + && grep -qE '^ detach\(\) \{' "$ANIMATED/AnimatedEvent.js" \ + || { echo "FAIL: AnimatedEvent.js attachNativeEventImpl no longer returns a handle that detaches"; exit 1; } +grep -qE '^ \.\.\.Animated,' "$ANIMATED/AnimatedExports.js" \ + && grep -qE "require\('\./AnimatedExports'\)" "$ANIMATED/Animated.js" \ + || { echo "FAIL: the public Animated object is no longer AnimatedExports' spread of AnimatedImplementation"; exit 1; } +grep -qE '^export default class AnimatedValue ' "$ANIMATED/nodes/AnimatedValue.js" \ + && grep -qE '^ __makeNative\(' "$ANIMATED/nodes/AnimatedValue.js" \ + || { echo "FAIL: AnimatedValue.js no longer default-exports a class with its own __makeNative"; exit 1; } +grep -qE 'animatedValue\.__makeNative\(' "$ANIMATED/animations/Animation.js" \ + && grep -qE 'value\.__makeNative\(' "$ANIMATED/AnimatedEvent.js" \ + || { echo "FAIL: an animation or an event no longer makes its value native through __makeNative"; exit 1; } +grep -qE 'animationType=\{animationType\}' "$RN_SRC/Libraries/Modal/Modal.js" \ + || { echo "FAIL: Modal.js no longer passes animationType to its host"; exit 1; } +# Tracked, not required: with C++ Animated on, the shadow tree holds a native animation's end value. +grep -qE 'cxxNativeAnimatedEnabled' "$RN_SRC/src/private/featureflags/ReactNativeFeatureFlags.js" \ + || echo " note: no cxxNativeAnimatedEnabled flag in this React Native" +echo " deletions, Animated component names and the Animated node fields 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