fix(mobile): give reanimated mapper hooks the inputs esbuild never writes (OTA phase C, C1.10) (#21592)

* refactor(mobile-web): extract the page render harness

The shell double, the CSP/bridge constant readers and the bundle server were
private to mobile-web-app-render.test.mjs, so a second check against the same
page had no way to reach them. Moved as-is into a module both can import; the
double also gained a `replies` map so a check can answer one method and leave
the refusal in place for everything else.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile): give reanimated mapper hooks a dependency array

The bottom drawer never slid onto the screen in the web shell page: `progress`
animated to 1 and `withTiming` reported finished, but the sheet kept the
translateY of the animation's first frame and sat one viewport below the fold,
with its invisible backdrop swallowing the next touch.

Cause, bisected in the browser: `useAnimatedStyle` reads its mapper inputs from
`updater.__closure` (hook/useAnimatedStyle.js), which only Reanimated's Babel
plugin writes. The page is bundled by esbuild, which runs no Babel, so
`__closure` is undefined; with no dependency array either, `inputs` is empty and
`startMapper` registers a mapper that listens to no shared value. It runs once
and never again. Reanimated does throw for exactly this, but behind `__DEV__`,
which the bundle builds out, so the page reports nothing. The rAF loop stopping
after one write is the observable end of it.

Not a WebKit fault. Headless Chromium parks the sheet the same way
(translateY(843) vs WebKit's translateY(841)), so the earlier
JavaScriptCore-vs-V8 reading does not hold, and the pin added here runs on both
engines rather than on Chromium alone. WebKit is downloaded in the
mobile_web_app job for it.

Every mapper-backed call site takes the same array, not just the drawer's:
RightDrawer and DragReorderList are the same defect on the same bundler.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census the reanimated hooks that need a dependency array

The drawer pin covers MountedBottomDrawer only, and the failure mode is silent:
a new `useAnimatedStyle`, `useAnimatedProps` or `useDerivedValue` without an
array animates once on the phone's native build and freezes in the web page,
with no error on either. Parsed rather than grepped so a call spanning lines,
or one whose second argument is not an array, is still seen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile-web): state motion-on as the drawer pin's precondition

Under `prefers-reduced-motion: reduce` Reanimated finishes `withTiming` in one
frame, so a mapper that only ever runs once still writes the final translateY
and the pin goes green on the broken build. Measured: the unfixed bundle under
reduced motion lands at translateY(0) with the sheet on screen in both engines,
which is also what the Android emulator does with animator scale off — the same
single write, not a healthy animation. The context now says no-preference and
the page is asked to confirm it.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): census useAnimatedReaction, whose deps are its third argument

Same fallback as the other three (hook/useAnimatedReaction.js:26-34), so the
same silent freeze applies. Its shape is not the same: the array is argument
three, behind `prepare` and `react`, and both callbacks run inside the one
mapper it starts, so both count as updaters. Indexing it like the others would
have read the `react` callback as the array. No call site today; this is the
gate for the first one.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): require the dependency array to list every value the updater reads

An array proves a call was written, not that the mapper listens to everything
it reads. On web `inputs` becomes exactly that array
(hook/useAnimatedStyle.js:338-341), so a value read but not listed is a value
the mapper never hears about: the updater stops re-running when only that one
changes. Same freeze as no array at all, in one prop rather than all of them.

Reads only. The first fixture caught this check counting `opacity.value = v` as
a read, which it is not -- a written value is an output, and demanding it in
the array would be noise at every `useAnimatedReaction`. Assignment targets and
increments are excluded; a value both read and written is still required.

Verified against the tree by dropping `translateY` from the bottom drawer's
array, which the census names at mounted-bottom-drawer.tsx:286.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): resolve the hook through the file's imports, not by spelling

Matching the callee's text both missed and invented. `useAnimatedStyle as useAS`
and `Reanimated.useAnimatedStyle` are the same hook wearing another name and
went unchecked; a local helper that happens to be called `useDerivedValue` is
not this hook and would have been flagged. Each local name is now resolved
through the file's imports from `react-native-reanimated`, named, aliased or
namespace member.

A second argument that is not a literal array now counts as present rather than
missing: the hook only needs an array to exist, and this file cannot see what a
hoisted `const deps = [...]` holds, so completeness covers literal arrays only.

Resolution can fail closed, which would read exactly like a clean tree, so the
census now asserts it saw the calls before asserting none are missing. Checked
against the tree by dropping `translateX` from RightDrawer's array, which it
names at RightDrawer.tsx:156.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile-web): select the drawer sheet by name, not by its corner radius

The pin walked up from the handle to the first ancestor with a 16px top radius,
so it found the sheet through a styling token. Change that radius and the pin
reports `sheet: false` -- a red naming the selector rather than the animation it
exists to watch, on a change that broke nothing.

The sheet now says what it is. `testID` on the RN side renders as `data-testid`
on web (react-native-web createDOMProps/index.js:832), which is the one line of
product change this needs.

Re-verified after retargeting: still red on both engines with the dependency
arrays removed (translateY 843.271 chromium, 841.447 webkit), green with them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* docs(mobile-web): say why the motion option must precede navigation

Reviewer follow-up on the reduced-motion guard. The context option and the
`goto` order are both load-bearing, and nothing in the file said so: Reanimated
reads `matchMedia('(prefers-reduced-motion: reduce)')` once into a module-level
const at import (ReducedMotion.js:8-10), so a `page.emulateMedia()` after
navigation would leave the assertion passing over a value already latched true.
Comment only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile-web): name the drawer pin's precondition instead of asserting past it

CI's Linux WebKit failed this pin at `matrix(1, 0, 0, 1, 0, 844)` -- exactly the
viewport, the mount-time value, not a first-frame 843.x. Nothing animated there,
so the pin was reporting a parked sheet without being able to say whether the
mapper was subscribed. Two different faults, one message.

`requestAnimationFrame` separates them and sheet writes do not. `withTiming`
schedules a frame per step (valueSetter.js) whether or not a mapper listens, so
frames across the window mean the shared value moved; the assertion now names
that. Counting sheet writes as the precondition inverts the diagnosis: measured
on the broken build, "written more than once" fires first and calls the defect
this pin exists to catch an engine that does not animate.

Sheet writes stay, as a second statement of the subject and as context in the
transform failure, which now reads "1 style write(s) on the sheet across 30
frame(s)" on the broken build.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile-web): wait for the drawer to arrive, not for a clock

The pin paused a fixed 1s after the sheet opened and then read the transform,
which makes it a race on a loaded runner: a healthy engine that is merely slow
reads as parked, and the red names the transform rather than the wait. It now
waits for the settled transform, times out at 15s, and asserts on whatever it
found either way, so a genuinely parked sheet gives the same red with the
timing assumption removed. On the broken build that red now reads "1 style
write(s) on the sheet across 3635 frame(s)", which says the fault in one line.

Aimed at CI's Linux WebKit red rather than proven against it: eight container
runs on the Playwright Linux image never reproduced that failure. See the
report for what the container did and did not show.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile-web): stop asserting on the sheet's style-write count

The count cannot carry an assertion in either direction. Measured under
`--cpus=0.35` in Playwright's Linux image, a healthy page starved of frames
reaches translateY(0) in a single write, because `withTiming` covers the whole
180ms in one step when one step is all the frames it gets. "Written more than
once" would have redded that page, which is a CI runner under load -- the exact
situation this pin keeps meeting.

So the transform is the only subject, `requestAnimationFrame` during the window
is the only precondition, and the write count is context in the failure text.

Also worth recording against the CI log: exactly `matrix(1, 0, 0, 1, 0, 844)`
is reproducible here on the broken build, as the single mapper run landing at
progress 0. It is the mapper's signature as much as a dead engine's, so it does
not on its own say which failed -- the frame and write counts now printed
beside it are what separate them.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile): count a value read under a unary operator as a read

`isWriteTarget` took any prefix-unary parent for a write, so `!hidden.value`,
`-offset.value`, `+x.value` and `~x.value` were dropped from the reads the
dependency array has to list. A style that gates on `!hidden.value` would have
passed the census while its mapper never listened to `hidden` -- the exact
freeze this file exists to catch, hidden by the check meant to catch it.

Only `++` and `--` mutate, so the prefix branch is narrowed to those two.
Postfix needs no narrowing: `++` and `--` are the whole set there.

Red-first with a negation fixture and a unary-minus fixture; the increment
fixture holds the other side, that a value only incremented is still not
required. Found by a review bot on #21592.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
This commit is contained in:
Jinwoo Hong
2026-09-19 03:47:44 -04:00
committed by GitHub
parent 4d3b0cca87
commit b7c06900e2
8 changed files with 875 additions and 213 deletions
+9 -2
View File
@@ -692,20 +692,27 @@ jobs:
"$chrome" --version
echo "ORCA_MOBILE_WEB_RENDER_BROWSER=$chrome" >> "$GITHUB_ENV"
# The drawer check runs on WebKit as well as Chrome, because the shell's iOS WebView is
# WebKit and the Chrome above cannot stand in for it. Downloaded rather than resolved from
# the runner: Ubuntu ships no WebKit build to point at.
- name: Install WebKit for the drawer check
run: pnpm exec playwright install --with-deps webkit
- name: Build and verify the app bundle
run: pnpm run build:mobile-web:app
# The bundling tests skip themselves where mobile dependencies are absent, which is how they
# stay green in the sharded `test` job. This is the job that installs them, so here a missing
# install has to fail rather than skip everything the job exists to run.
- name: Builder, override census and render check
- name: Builder, override census and render checks
env:
ORCA_MOBILE_WEB_APP_DEPS_REQUIRED: '1'
run: |
pnpm exec vitest run --config config/vitest.config.ts \
config/scripts/build-mobile-web-app-bundle.test.mjs \
config/scripts/mobile-web-app-web-overrides.test.mjs \
config/scripts/mobile-web-app-render.test.mjs
config/scripts/mobile-web-app-render.test.mjs \
config/scripts/mobile-web-app-drawer-render.test.mjs
cross-version-wire:
name: cross-version wire compatibility
@@ -0,0 +1,272 @@
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium, webkit } from 'playwright-core'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
createBundleServer,
installShellDouble,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
const HOST_ROUTE = '/h/render-check-host'
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
const VIEWPORT = { width: 390, height: 844 }
/**
* Both engines, because the defect this pins is not engine-specific.
*
* `useAnimatedStyle` without a dependency array registers a Reanimated mapper with no inputs
* (hook/useAnimatedStyle.js reads `updater.__closure`, which only the Babel plugin writes and
* esbuild never does). The mapper then runs once and never again, so the sheet keeps whichever
* translateY the first frame wrote. Chromium and WebKit both park it, so a Chromium-only pin
* would go green on an engine-specific theory that is not what is happening.
*/
const ENGINES = [
{
name: 'chromium',
// CI runs this against the runner's Google Chrome rather than paying for a browser download,
// the same override shape as the render check next door.
launch: () => {
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
return chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {})
})
}
},
{ name: 'webkit', launch: () => webkit.launch({ headless: true }) }
]
const bundles = mobileWebAppDependenciesPresent()
const describeDrawer = bundles ? describe : describe.skip
let scratch
let server
let origin
let cspHeader = null
let bridgeVersion = null
let faultGrant = null
beforeAll(async () => {
if (!bundles) {
return
}
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-drawer-'))
const { outDir } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
const served = await createBundleServer({ outDir, cspHeader })
server = served.server
origin = served.origin
}, 180_000)
afterAll(async () => {
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
/**
* The sheet itself, by the name it gives itself.
*
* Not by its corner radius: that selected the sheet through a styling token, so a design change
* to the radius would have turned this pin into `sheet: false` -- a failure naming the wrong
* thing entirely. `testID` on the RN side renders as `data-testid`
* (react-native-web createDOMProps/index.js:832).
*/
function readDrawer() {
const handle = document.querySelector('[aria-label="Dismiss drawer"]')
if (!handle) {
return { open: false }
}
const sheet = document.querySelector('[data-testid="bottom-drawer-sheet"]')
if (!sheet) {
return { open: true, sheet: false }
}
const box = sheet.getBoundingClientRect()
return {
open: true,
sheet: true,
transform: getComputedStyle(sheet).transform,
top: Math.round(box.top),
bottom: Math.round(box.bottom),
height: Math.round(box.height)
}
}
/**
* Installed at document start, so the counters cover the page's whole life rather than a window
* a poll happened to catch. Both are the page's own activity: `__raf` is every frame the page
* asked for, `__sheetWrites` every inline-style write Reanimated landed on the sheet.
*/
function instrumentFrames() {
globalThis.__raf = 0
const realRaf = globalThis.requestAnimationFrame.bind(globalThis)
globalThis.requestAnimationFrame = (callback) => {
globalThis.__raf++
return realRaf(callback)
}
globalThis.__sheetWrites = 0
const observe = () => {
new MutationObserver((records) => {
for (const record of records) {
if (record.target.dataset?.testid === 'bottom-drawer-sheet') {
globalThis.__sheetWrites++
}
}
}).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['style'] })
}
if (document.body) {
observe()
} else {
document.addEventListener('DOMContentLoaded', observe)
}
}
/** The centre of the one leaf element whose whole text is `label`. */
function centreOf(label) {
const leaf = [...document.querySelectorAll('*')].find(
(element) => element.childElementCount === 0 && element.textContent === label
)
if (!leaf) {
return null
}
const box = leaf.getBoundingClientRect()
return { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) }
}
describeDrawer('the bottom drawer on the page', () => {
for (const engine of ENGINES) {
it(`slides the sheet onto the screen in ${engine.name}`, async () => {
const browser = await engine.launch()
try {
// Motion on, stated rather than inherited. Under `prefers-reduced-motion: reduce`
// Reanimated finishes `withTiming` in one frame, so a mapper that only ever runs once
// still lands on the final translateY and this pin would pass on the broken build.
// Context-level and before navigation, both load-bearing: Reanimated latches the query
// into a module-level const at import (ReducedMotion.js:8-10), so an `emulateMedia` call
// after `goto` would leave the assertion below passing over an already-latched `true`.
const page = await browser.newPage({
viewport: VIEWPORT,
reducedMotion: 'no-preference'
})
const errors = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
await page.addInitScript(instrumentFrames)
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: 'render-check-session',
buildId: 'render-check-build',
route: { pathname: HOST_ROUTE },
host: SHELL_HOST,
storage: {},
faultGrant
})
await page.goto(`${origin}/`, { waitUntil: 'load' })
// The precondition the assertions below rest on, read off the page rather than assumed.
expect(
await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)
).toBe(false)
await page.waitForFunction(
() => document.documentElement.dataset.orcaWebEntry === 'mounted',
{ timeout: 30_000, polling: 250 }
)
// The filter sheet, not the row's action sheet: both are the same MountedBottomDrawer, and
// this one opens from the header, which needs nothing of the list's own layout.
const chip = await page.waitForFunction(centreOf, 'Filter', {
timeout: 30_000,
polling: 250
})
const at = await chip.jsonValue()
// Both counters start at the click, so what they measure is the enter animation's window
// and not everything the page did while it was booting.
await page.evaluate(() => {
globalThis.__rafAtClick = globalThis.__raf
globalThis.__sheetWrites = 0
})
await page.mouse.click(at.x, at.y)
const opened = await page
.waitForFunction(
() => {
const handle = document.querySelector('[aria-label="Dismiss drawer"]')
return handle ? true : null
},
{ timeout: 10_000, polling: 100 }
)
.then(() => true)
expect(opened, errors.join(' | ')).toBe(true)
// Wait for the animation to arrive rather than for a clock. A fixed pause makes the pin
// a race on a loaded runner: too short and a healthy-but-slow engine reads as parked,
// and the failure names the transform instead of the wait. A sheet that is genuinely
// parked never moves, so this times out and the assertions below still report what it
// found -- the same red, minus the timing assumption.
await page
.waitForFunction(
() => {
const sheet = document.querySelector('[data-testid="bottom-drawer-sheet"]')
return sheet && getComputedStyle(sheet).transform === 'matrix(1, 0, 0, 1, 0, 0)'
? true
: null
},
{ timeout: 15_000, polling: 50 }
)
.catch(() => null)
const drawer = await page.evaluate(readDrawer)
expect(drawer.sheet, JSON.stringify(drawer)).toBe(true)
// The precondition, named, because the transform below cannot on its own tell a mapper
// that is not subscribed from an engine that never ran the animation at all. Both leave
// a parked sheet and only the first is this pin's subject.
//
// `requestAnimationFrame` is the one that separates them. `withTiming` drives itself by
// scheduling a frame per step (valueSetter.js `step`), and it does that whether or not
// any mapper is listening, so frames during this window mean the shared value moved.
// Sheet writes separate nothing and are carried as context only. The broken build writes
// once, an engine that never animated writes once, and -- measured under `--cpus=0.35`
// in Playwright's Linux image -- a healthy page starved of frames also reaches
// translateY(0) in a single write, because `withTiming` covers the whole 180ms in one
// step when that is all the frames it gets. Asserting on the count would red that page.
const frames = await page.evaluate(() => ({
raf: globalThis.__raf - globalThis.__rafAtClick,
sheetWrites: globalThis.__sheetWrites
}))
expect(
frames.raf,
`${engine.name}: the page was given no animation frames after the sheet opened, so ` +
'the enter animation never ran and the transform proves nothing about the mapper'
).toBeGreaterThan(0)
// Reanimated's own write, once its mapper has run to the end of `progress`. The initial
// inline style is a full viewport of translateY, so a mapper that stopped after its first
// frame leaves a matrix here with a large offset instead of none.
expect(
drawer.transform,
`${engine.name}: ${String(frames.sheetWrites)} style write(s) on the sheet across ` +
`${String(frames.raf)} frame(s) -- ${JSON.stringify(drawer)}`
).toBe('matrix(1, 0, 0, 1, 0, 0)')
// And where that leaves the sheet: bottom-anchored inside the viewport, which is the
// thing the user sees and the thing a parked sheet gets wrong.
expect(drawer.bottom, JSON.stringify(drawer)).toBe(VIEWPORT.height)
expect(drawer.top, JSON.stringify(drawer)).toBeGreaterThan(0)
expect(errors).toEqual([])
await page.close()
} finally {
await browser.close()
}
}, 120_000)
}
})
@@ -0,0 +1,224 @@
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
export const projectDir = fileURLToPath(new URL('../..', import.meta.url))
/**
* Both CSP constants are a list of quoted directives with `//` comments between them, and those
* comments quote directive text. Dropping comment lines first is what keeps a comment out of the
* header a test serves.
*/
export function parseCspDirectives(source, startMarker, endMarker) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker)
if (start === -1 || end < start) {
throw new Error(`could not find ${startMarker} .. ${endMarker}`)
}
const body = source
.slice(start, end)
.split('\n')
.filter((line) => !line.trimStart().startsWith('//'))
.join('\n')
const directives = [...body.matchAll(/"([^"]+)"/g)].map((match) => match[1])
if (directives.length < 10) {
throw new Error('could not parse the shell CSP')
}
return directives.join('; ')
}
/**
* The shipped policy, read from the Kotlin source so a test cannot drift from what the shell
* actually sends. Parsed rather than imported: the constant lives in a JVM module.
*/
export async function readShellCsp() {
const source = await readFile(
join(
projectDir,
'mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt'
),
'utf8'
)
return parseCspDirectives(source, 'listOf(', ').joinToString')
}
/**
* The envelope version the page speaks, read from the contract rather than written down twice. A
* bumped `v` would otherwise reach a test as a 30s timeout naming nothing.
*/
export async function readBridgeProtocolVersion() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
'utf8'
)
const match = /BRIDGE_PROTOCOL_VERSION = (\d+)/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_PROTOCOL_VERSION')
}
return Number(match[1])
}
/** The grant the shell offers every page, read from the same source for the same reason. */
export async function readBridgeFaultGrant() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
'utf8'
)
const match = /BRIDGE_FAULT_GRANT = '([a-zA-Z]+)'/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_FAULT_GRANT')
}
return match[1]
}
/**
* The shell's half of the bridge, as the page's channel sees it.
*
* The entry mounts nothing until `init` lands, so a render check with no shell renders no route at
* all. This answers `ready`, answers the methods `replies` names, and refuses everything else: a
* real reply would make this file the place domain behaviour is decided, and every screen below
* already has a state for an RPC that failed. `grants` and `pageRoutes` are what the shell would
* have negotiated, and every notify the page posts is kept whole in `__orcaRenderCheckNotifies`,
* because a control that handed something to the shell and one that did nothing look the same on
* the document.
*
* Serialized as a page init script, so it takes plain data and closes over nothing.
*/
export function installShellDouble({
version,
sessionId,
buildId,
route,
host,
storage,
faultGrant,
grants,
pageRoutes = null,
replies
}) {
// Where the page's own fault reports land. Read back after the render, so a route that threw
// under the boundary names itself instead of timing out as a page that never mounted.
globalThis.__orcaRenderCheckFaults = []
// Every grant-gated notify the page posted, whole and in order. A control that decided to hand
// something to the shell and a control that did nothing look identical on the document; this is
// the only thing that tells them apart.
globalThis.__orcaRenderCheckNotifies = []
const channel = {
postMessage: (json) => {
const frame = JSON.parse(json)
const answer = (message) => {
// A microtask, not a task: the page posts `ready` while its script is still running, and
// this keeps the answer behind it without moving a timer the page's backoff reads.
queueMicrotask(() => {
channel.onmessage?.({ data: JSON.stringify(message) })
})
}
if (frame.type === 'ready') {
answer({
v: version,
type: 'init',
sessionId,
buildId,
connection: {
state: 'connected',
reconnectAttempt: 0,
lastConnectedAt: 1,
lastInboundAt: 1,
generation: 0
},
grants: {
rpc: { maxPendingRequests: 64, maxSubscriptions: 32 },
// The fault grant alone unless the caller named a set: every check needs that one,
// and a check that names none must not be handed an undefined list.
native: grants ?? [faultGrant]
},
...(pageRoutes === null ? {} : { pageRoutes }),
// Omitted for a shell too old to name one, which is the case the page has a panel for.
...(route === null ? {} : { route }),
...(host === null ? {} : { host }),
storage
})
return
}
if (frame.type === 'notify') {
globalThis.__orcaRenderCheckNotifies.push(frame)
if (frame.name === faultGrant) {
globalThis.__orcaRenderCheckFaults.push(frame.error.message)
}
return
}
// The result the caller named for this method, carried in the envelope a real host uses.
// Anything unnamed still takes the refusal below, so a screen only ever sees data a test
// asked for.
if (frame.type === 'request' && replies && Object.hasOwn(replies, frame.method)) {
answer({
v: version,
type: 'reply',
id: frame.id,
payload: { id: frame.id, ok: true, result: replies[frame.method] }
})
return
}
if (frame.type === 'request' || frame.type === 'subscribe') {
answer({
v: version,
type: 'error',
id: frame.id,
error: {
category: 'RenderCheckShellDouble',
message: 'the render check answers no RPC',
isRpcDeliveryUnknown: false
}
})
}
},
onmessage: null
}
globalThis.orcaBridge = channel
}
/**
* The page server the render checks run against: the built bundle, under the shell's own policy.
*
* `transformChunk` is how a check poisons one route chunk without building a second bundle.
*/
export async function createBundleServer({ outDir, cspHeader, transformChunk }) {
const server = createServer((request, response) => {
const path = new URL(request.url, 'http://localhost').pathname
// A browser asks for this on its own and the shell's WebView never does. The bundle carries
// no icon, so a 404 would put a console error in every check that runs against a full Chrome
// -- which is what CI resolves -- and none against the bundled headless shell.
if (path === '/favicon.ico') {
response.writeHead(204)
response.end()
return
}
// A route path serves the entrypoint and the page routes client-side. A path naming a file
// has to come out of the bundle or 404, the same as the shell's manifest map: answering it
// with the document instead would hide a publicPath the script cannot fetch from.
const namesAFile = path.slice(path.lastIndexOf('/')).includes('.')
const file = namesAFile ? path.slice(1) : 'index.html'
readFile(join(outDir, file)).then(
(real) => {
const bytes = transformChunk ? transformChunk(path, real) : real
const headers = {
'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/html'
}
// The document carries the shell's real policy, so a directive the page violates fails
// here rather than on a phone. Assets carry none, exactly as the native handler does.
if (file === 'index.html' && cspHeader) {
headers['content-security-policy'] = cspHeader
}
response.writeHead(200, headers)
response.end(bytes)
},
() => {
response.writeHead(404)
response.end()
}
)
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
return { server, origin: `http://127.0.0.1:${String(server.address().port)}` }
}
+21 -198
View File
@@ -1,14 +1,19 @@
import { createServer } from 'node:http'
import { mkdtemp, readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium } from 'playwright-core'
import { fileURLToPath } from 'node:url'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
const projectDir = fileURLToPath(new URL('../..', import.meta.url))
import {
createBundleServer,
installShellDouble,
parseCspDirectives,
projectDir,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
// Why a real browser: the route tree is handed to expo-router's own ExpoRoot through a synthesized
// RequireContext. Nothing short of mounting it proves that object is the shape ExpoRoot reads.
@@ -53,159 +58,6 @@ let faultGrant = null
const poisonedChunks = new Set()
const POISON_MESSAGE = 'render check poisoned this route chunk'
/**
* Both CSP constants are a list of quoted directives with `//` comments between them, and those
* comments quote directive text. Dropping comment lines first is what keeps a comment out of the
* header this test serves.
*/
export function parseCspDirectives(source, startMarker, endMarker) {
const start = source.indexOf(startMarker)
const end = source.indexOf(endMarker)
if (start === -1 || end < start) {
throw new Error(`could not find ${startMarker} .. ${endMarker}`)
}
const body = source
.slice(start, end)
.split('\n')
.filter((line) => !line.trimStart().startsWith('//'))
.join('\n')
const directives = [...body.matchAll(/"([^"]+)"/g)].map((match) => match[1])
if (directives.length < 10) {
throw new Error('could not parse the shell CSP')
}
return directives.join('; ')
}
/**
* The envelope version the page speaks, read from the contract rather than written down twice. A
* bumped `v` would otherwise reach this file as a 30s timeout naming nothing.
*/
async function readBridgeProtocolVersion() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
'utf8'
)
const match = /BRIDGE_PROTOCOL_VERSION = (\d+)/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_PROTOCOL_VERSION')
}
return Number(match[1])
}
/** The grant the shell offers every page, read from the same source for the same reason. */
async function readBridgeFaultGrant() {
const source = await readFile(
join(projectDir, 'mobile/src/mobile-web-shell/bridge/bridge-envelope.ts'),
'utf8'
)
const match = /BRIDGE_FAULT_GRANT = '([a-zA-Z]+)'/.exec(source)
if (!match) {
throw new Error('could not read BRIDGE_FAULT_GRANT')
}
return match[1]
}
/**
* The shell's half of the bridge, as the page's channel sees it.
*
* The entry mounts nothing until `init` lands, so a render check with no shell renders no route at
* all. This answers `ready` and refuses everything else: a real reply would make this file the
* place domain behaviour is decided, and every screen below already has a state for an RPC that
* failed. The one message that matters here is the one that lets the tree mount.
*/
function installShellDouble({
version,
sessionId,
buildId,
route,
host,
storage,
faultGrant,
grants,
pageRoutes
}) {
// Where the page's own fault reports land. Read back after the render, so a route that threw
// under the boundary names itself instead of timing out as a page that never mounted.
globalThis.__orcaRenderCheckFaults = []
// Every grant-gated notify the page posted, whole and in order. A control that decided to hand
// something to the shell and a control that did nothing look identical on the document; this is
// the only thing that tells them apart.
globalThis.__orcaRenderCheckNotifies = []
const channel = {
postMessage: (json) => {
const frame = JSON.parse(json)
const answer = (message) => {
// A microtask, not a task: the page posts `ready` while its script is still running, and
// this keeps the answer behind it without moving a timer the page's backoff reads.
queueMicrotask(() => {
channel.onmessage?.({ data: JSON.stringify(message) })
})
}
if (frame.type === 'ready') {
answer({
v: version,
type: 'init',
sessionId,
buildId,
connection: {
state: 'connected',
reconnectAttempt: 0,
lastConnectedAt: 1,
lastInboundAt: 1,
generation: 0
},
grants: {
rpc: { maxPendingRequests: 64, maxSubscriptions: 32 },
native: grants
},
...(pageRoutes === null ? {} : { pageRoutes }),
// Omitted for a shell too old to name one, which is the case the page has a panel for.
...(route === null ? {} : { route }),
...(host === null ? {} : { host }),
storage
})
return
}
if (frame.type === 'notify') {
globalThis.__orcaRenderCheckNotifies.push(frame)
if (frame.name === faultGrant) {
globalThis.__orcaRenderCheckFaults.push(frame.error.message)
}
return
}
if (frame.type === 'request' || frame.type === 'subscribe') {
answer({
v: version,
type: 'error',
id: frame.id,
error: {
category: 'RenderCheckShellDouble',
message: 'the render check answers no RPC',
isRpcDeliveryUnknown: false
}
})
}
},
onmessage: null
}
globalThis.orcaBridge = channel
}
/**
* The shipped policy, read from the Kotlin source so this test cannot drift from what the shell
* actually sends. Parsed rather than imported: the constant lives in a JVM module.
*/
async function readShellCsp() {
const source = await readFile(
join(
projectDir,
'mobile/modules/orca-mobile-web-shell/android/src/main/java/expo/modules/orcamobilewebshell/MobileWebShellCsp.kt'
),
'utf8'
)
return parseCspDirectives(source, 'listOf(', ').joinToString')
}
beforeAll(async () => {
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
@@ -217,48 +69,19 @@ beforeAll(async () => {
const built = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
const { outDir } = built
routeChunks = built.routeChunks
server = createServer((request, response) => {
const path = new URL(request.url, 'http://localhost').pathname
// A browser asks for this on its own and the shell's WebView never does. The bundle carries
// no icon, so a 404 would put a console error in every check that runs against a full Chrome
// -- which is what CI resolves -- and none against the bundled headless shell.
if (path === '/favicon.ico') {
response.writeHead(204)
response.end()
return
}
// A route path serves the entrypoint and the page routes client-side. A path naming a file
// has to come out of the bundle or 404, the same as the shell's manifest map: answering it
// with the document instead would hide a publicPath the script cannot fetch from.
const namesAFile = path.slice(path.lastIndexOf('/')).includes('.')
const file = namesAFile ? path.slice(1) : 'index.html'
readFile(join(outDir, file)).then(
(real) => {
// The real bytes with a throw in front: the module still links, so the importer resolves
// every export it asked for and then evaluation throws. A body replaced outright fails at
// link instead, which is a different failure from the one the boundary is here for.
const bytes = poisonedChunks.has(path)
? `throw new Error(${JSON.stringify(POISON_MESSAGE)});\n${real.toString('utf8')}`
: real
const headers = {
'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/html'
}
// The document carries the shell's real policy, so a directive the page violates fails
// here rather than on a phone. Assets carry none, exactly as the native handler does.
if (file === 'index.html' && cspHeader) {
headers['content-security-policy'] = cspHeader
}
response.writeHead(200, headers)
response.end(bytes)
},
() => {
response.writeHead(404)
response.end()
}
)
// The real bytes with a throw in front: the module still links, so the importer resolves
// every export it asked for and then evaluation throws. A body replaced outright fails at
// link instead, which is a different failure from the one the boundary is here for.
const served = await createBundleServer({
outDir,
cspHeader,
transformChunk: (path, real) =>
poisonedChunks.has(path)
? `throw new Error(${JSON.stringify(POISON_MESSAGE)});\n${real.toString('utf8')}`
: real
})
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
origin = `http://127.0.0.1:${String(server.address().port)}`
server = served.server
origin = served.origin
// CI runs this against the runner's Google Chrome rather than paying for a browser download,
// the same reason and the same override shape as the orcad browser-provider job.
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
+1 -1
View File
@@ -286,7 +286,7 @@ function DragReorderRow({
backgroundColor: colors.bgPanel,
transform: [{ scale: 1 }]
}
})
}, [positions, activeKey, activeTop, rowKey, rowHeight])
return (
<Animated.View style={[styles.row, { height: rowHeight }, rowStyle]}>
+13 -10
View File
@@ -153,20 +153,23 @@ function MountedRightDrawer({
}
})
const drawerStyle = useAnimatedStyle(() => ({
transform: [
{
translateX:
interpolate(progress.value, [0, 1], [panelWidth, 0], Extrapolation.CLAMP) +
translateX.value
}
]
}))
const drawerStyle = useAnimatedStyle(
() => ({
transform: [
{
translateX:
interpolate(progress.value, [0, 1], [panelWidth, 0], Extrapolation.CLAMP) +
translateX.value
}
]
}),
[progress, translateX, panelWidth]
)
const backdropStyle = useAnimatedStyle(() => {
const dragFade = interpolate(translateX.value, [0, panelWidth], [1, 0], Extrapolation.CLAMP)
return { opacity: progress.value * dragFade }
})
}, [progress, translateX, panelWidth])
return (
<Animated.View
@@ -298,12 +298,12 @@ export function MountedBottomDrawer({
}
]
}
})
}, [progress, translateY, keyboardOffset, screenHeight, fillAvailable])
const backdropStyle = useAnimatedStyle(() => {
const dragFade = interpolate(translateY.value, [0, 300], [1, 0], Extrapolation.CLAMP)
return { opacity: progress.value * dragFade }
})
}, [progress, translateY])
// Why: the sheet renders through a full-screen native window (its own Modal
// below, or the shared BottomDrawerModalHost) so it always covers the viewport
@@ -382,6 +382,8 @@ export function MountedBottomDrawer({
<Animated.View
// Why: remount per window hand-back — see the windowEpoch effect.
key={windowEpoch}
// The sheet names itself so a check can find it without reading its styling.
testID="bottom-drawer-sheet"
style={[
styles.drawer,
fillAvailable ? styles.drawerFill : null,
@@ -0,0 +1,331 @@
import { readFileSync, readdirSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { extname, join, relative } from 'node:path'
import ts from 'typescript'
import { describe, expect, it } from 'vitest'
const mobileDirectory = fileURLToPath(new URL('..', import.meta.url))
const scanned = ['src', 'app']
const sourceExtensions = new Set(['.ts', '.tsx'])
/**
* Reanimated hooks whose updater becomes a mapper, and which therefore need to know which shared
* values the updater reads.
*
* On native the Babel plugin writes `updater.__closure` and Reanimated reads the inputs off it.
* The mobile web bundle is built by esbuild (config/scripts/build-mobile-web-app-bundle.mjs), which
* runs no Babel, so `__closure` is undefined and `inputs` falls back to the dependency array —
* and with neither, `startMapper` registers a mapper that listens to nothing. It runs once and
* never again, freezing whatever the first frame wrote. That is silent: the throw Reanimated has
* for this case is behind `__DEV__`, which the bundle builds out.
*/
const MAPPER_HOOKS = new Map([
['useAnimatedStyle', { updaters: [0], dependencies: 1 }],
['useAnimatedProps', { updaters: [0], dependencies: 1 }],
['useDerivedValue', { updaters: [0], dependencies: 1 }],
// Third argument, not second: `useAnimatedReaction(prepare, react, dependencies)`. Both
// callbacks run inside the one mapper it starts (hook/useAnimatedReaction.js:38-50), so both
// are updaters.
['useAnimatedReaction', { updaters: [0, 1], dependencies: 2 }]
])
function sourceFiles(directory: string): string[] {
return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
const path = join(directory, entry.name)
if (entry.isDirectory()) {
return entry.name === 'node_modules' ? [] : sourceFiles(path)
}
return sourceExtensions.has(extname(entry.name)) ? [path] : []
})
}
/** Whether this `X.value` is being written rather than read. A write is an output, not an input. */
function isWriteTarget(node: ts.PropertyAccessExpression): boolean {
const parent = node.parent
if (ts.isBinaryExpression(parent) && parent.left === node) {
// `=` through `??=`: every assignment operator sits in this one contiguous token range.
return (
parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment
)
}
if (ts.isPrefixUnaryExpression(parent)) {
// Only `++x` and `--x` mutate. `!x.value`, `-x.value`, `+x.value` and `~x.value` are reads,
// and taking every prefix operator for a write dropped those from the array's requirement.
return (
parent.operator === ts.SyntaxKind.PlusPlusToken ||
parent.operator === ts.SyntaxKind.MinusMinusToken
)
}
// Postfix has no other operators: `x.value++` and `x.value--` are the whole set.
return ts.isPostfixUnaryExpression(parent)
}
/**
* What each local name in this file means, for the hooks above, resolved through its imports.
*
* Matching on the callee's spelling would both miss and invent: `useAnimatedStyle as useAS` and
* `Reanimated.useAnimatedStyle` are the same hook under another name, and a local helper that
* happens to be called `useDerivedValue` is not this hook at all. Returns the local identifiers
* bound to each hook, plus the namespace names a member access has to go through.
*/
function reanimatedBindings(sourceFile: ts.SourceFile): {
byLocalName: Map<string, string>
namespaces: Set<string>
} {
const byLocalName = new Map<string, string>()
const namespaces = new Set<string>()
for (const statement of sourceFile.statements) {
if (
!ts.isImportDeclaration(statement) ||
!ts.isStringLiteral(statement.moduleSpecifier) ||
statement.moduleSpecifier.text !== 'react-native-reanimated'
) {
continue
}
const bindings = statement.importClause?.namedBindings
if (bindings && ts.isNamespaceImport(bindings)) {
namespaces.add(bindings.name.text)
}
if (bindings && ts.isNamedImports(bindings)) {
for (const element of bindings.elements) {
const imported = element.propertyName?.text ?? element.name.text
if (MAPPER_HOOKS.has(imported)) {
byLocalName.set(element.name.text, imported)
}
}
}
// The default export is the `Animated` namespace object, which carries no hooks.
}
return { byLocalName, namespaces }
}
/** The hook this callee names, or null when it is not one of ours. */
function resolveHook(
callee: ts.Expression,
bindings: ReturnType<typeof reanimatedBindings>
): string | null {
if (ts.isIdentifier(callee)) {
return bindings.byLocalName.get(callee.text) ?? null
}
if (
ts.isPropertyAccessExpression(callee) &&
ts.isIdentifier(callee.expression) &&
bindings.namespaces.has(callee.expression.text) &&
MAPPER_HOOKS.has(callee.name.text)
) {
return callee.name.text
}
return null
}
/** Every `X` in an `X.value` read under this node, which is what the mapper has to listen to. */
function sharedValuesRead(updater: ts.Node): Set<string> {
const names = new Set<string>()
const visit = (node: ts.Node): void => {
if (
ts.isPropertyAccessExpression(node) &&
node.name.text === 'value' &&
ts.isIdentifier(node.expression) &&
!isWriteTarget(node)
) {
names.add(node.expression.text)
}
ts.forEachChild(node, visit)
}
visit(updater)
return names
}
/** The identifiers a dependency array lists, ignoring entries that are not plain names. */
function namesListed(dependencies: ts.ArrayLiteralExpression): Set<string> {
return new Set(dependencies.elements.filter(ts.isIdentifier).map((element) => element.text))
}
/**
* Every mapper-hook call that was not handed a dependency array, or was handed one that leaves a
* shared value out.
*
* The second half is the one an array alone does not give: `inputs` becomes exactly the array
* (hook/useAnimatedStyle.js:338-341), so a value the updater reads but the array omits is a value
* the mapper never listens to. That updater then stops re-running when only that value changes,
* which is the same freeze as having no array at all, in one prop instead of all of them.
*/
function callsMissingDependencies(path: string, source: string, found: string[] = []): string[] {
const sourceFile = ts.createSourceFile(
path,
source,
ts.ScriptTarget.Latest,
true,
extname(path) === '.tsx' ? ts.ScriptKind.TSX : ts.ScriptKind.TS
)
const bindings = reanimatedBindings(sourceFile)
const missing: string[] = []
const visit = (node: ts.Node): void => {
if (ts.isCallExpression(node)) {
const name = resolveHook(node.expression, bindings)
const hook = name === null ? undefined : MAPPER_HOOKS.get(name)
if (name !== null && hook) {
found.push(name)
const dependencies = node.arguments[hook.dependencies]
const { line } = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile))
const where = `${relative(mobileDirectory, path)}:${String(line + 1)} ${name}`
if (!dependencies) {
missing.push(where)
} else if (!ts.isArrayLiteralExpression(dependencies)) {
// An array built elsewhere counts as present: the hook only needs one to exist, and
// this file cannot see what a hoisted `const deps = [...]` holds. Completeness below
// therefore covers literal arrays only.
} else {
const listed = namesListed(dependencies)
const read = hook.updaters.flatMap((index) => {
const updater = node.arguments[index]
return updater ? [...sharedValuesRead(updater)] : []
})
for (const value of [...new Set(read)].sort()) {
if (!listed.has(value)) {
missing.push(`${where} omits ${value}`)
}
}
}
}
}
ts.forEachChild(node, visit)
}
visit(sourceFile)
return missing
}
describe('reanimated mapper hooks in the web bundle', () => {
it('are all given a dependency array, because esbuild writes no worklet closure', () => {
const found: string[] = []
const missing = scanned.flatMap((directory) =>
sourceFiles(join(mobileDirectory, directory)).flatMap((path) =>
path.endsWith('.test.ts') || path.endsWith('.test.tsx')
? []
: callsMissingDependencies(path, readFileSync(path, 'utf8'), found)
)
)
// The precondition the empty list above rests on. Binding resolution means a broken resolver
// reports nothing at all, which would read exactly like a clean tree.
expect(found.length).toBeGreaterThanOrEqual(5)
expect(missing).toEqual([])
})
const FROM = "import { useAnimatedStyle, useAnimatedReaction } from 'react-native-reanimated'\n"
it('finds a call with no dependency array, which is what makes the census above real', () => {
const found = callsMissingDependencies(
'fixture.tsx',
`${FROM}const s = useAnimatedStyle(() => ({ opacity: progress.value }))\n`
)
expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle'])
})
it('reads useAnimatedReaction dependencies from its third argument, not its second', () => {
expect(
callsMissingDependencies(
'fixture.tsx',
`${FROM}useAnimatedReaction(() => progress.value, (v) => { opacity.value = v })\n`
)
).toEqual(['fixture.tsx:2 useAnimatedReaction'])
expect(
callsMissingDependencies(
'fixture.tsx',
`${FROM}useAnimatedReaction(() => progress.value, (v) => { opacity.value = v }, [progress])\n`
)
).toEqual([])
})
it('names a shared value the updater reads but the array leaves out', () => {
const found = callsMissingDependencies(
'fixture.tsx',
`${FROM}const s = useAnimatedStyle(() => ({ opacity: progress.value * fade.value }), [progress])\n`
)
expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle omits fade'])
})
it('does not ask for a value the updater only writes, which is an output', () => {
const found = callsMissingDependencies(
'fixture.tsx',
`${FROM}useAnimatedReaction(() => progress.value, (v) => { opacity.value = v }, [progress])\n`
)
expect(found).toEqual([])
})
it('still asks for one that is read and written', () => {
const found = callsMissingDependencies(
'fixture.tsx',
`${FROM}const s = useAnimatedStyle(() => { offset.value = offset.value + 1; return {} }, [])\n`
)
expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle omits offset'])
})
it('still asks for a value read under a negation, which is not a write', () => {
const found = callsMissingDependencies(
'fixture.tsx',
`${FROM}const s = useAnimatedStyle(() => ({ opacity: !hidden.value ? 1 : 0 }), [])\n`
)
expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle omits hidden'])
})
it('and one read under a unary minus', () => {
const found = callsMissingDependencies(
'fixture.tsx',
`${FROM}const s = useAnimatedStyle(() => ({ top: -offset.value }), [])\n`
)
expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle omits offset'])
})
it('does not ask for one that is only incremented', () => {
const found = callsMissingDependencies(
'fixture.tsx',
`${FROM}const s = useAnimatedStyle(() => { count.value++; return {} }, [])\n`
)
expect(found).toEqual([])
})
it('accepts one that has a dependency array', () => {
const found = callsMissingDependencies(
'fixture.tsx',
`${FROM}const s = useAnimatedStyle(() => ({ opacity: progress.value }), [progress])\n`
)
expect(found).toEqual([])
})
it('sees the hook through an alias, which spelling alone would miss', () => {
const found = callsMissingDependencies(
'fixture.tsx',
"import { useAnimatedStyle as useAS } from 'react-native-reanimated'\n" +
'const s = useAS(() => ({ opacity: progress.value }))\n'
)
expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle'])
})
it('sees it through a namespace import too', () => {
const found = callsMissingDependencies(
'fixture.tsx',
"import * as Reanimated from 'react-native-reanimated'\n" +
'const s = Reanimated.useAnimatedStyle(() => ({ opacity: progress.value }))\n'
)
expect(found).toEqual(['fixture.tsx:2 useAnimatedStyle'])
})
it('leaves a local function of the same name alone', () => {
const found = callsMissingDependencies(
'fixture.tsx',
'function useDerivedValue(fn: () => number) { return fn() }\n' +
'const v = useDerivedValue(() => progress.value)\n'
)
expect(found).toEqual([])
})
it('takes an array built elsewhere as present rather than missing', () => {
const found = callsMissingDependencies(
'fixture.tsx',
`${FROM}const deps = [progress]\n` +
'const s = useAnimatedStyle(() => ({ opacity: progress.value }), deps)\n'
)
expect(found).toEqual([])
})
})