Files
orca/config/scripts/mobile-web-app-drawer-render.test.mjs
T
Jinwoo Hong b7c06900e2 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
2026-09-19 03:47:44 -04:00

273 lines
11 KiB
JavaScript

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)
}
})