Files
orca/config/scripts/mobile-web-app-drawer-render.test.mjs
T
Jinwoo Hong 11db2b9a7d feat(mobile): the device Back key reaches the page (#22308)
* feat(mobile): the page can claim the device Back key

The shell's page had no way to hear Android Back: every sheet inside it
early-returned on web, so the key popped the whole session route. Adds the
first negotiated shell-to-page frame kind alongside it.

- `back-claim`, page to shell, declared in `init.accepts`: the document is
  holding the key, or has let it go.
- `back`, shell to page, declared in `ready.accepts`: one press, dispatched to
  the newest consumer that takes it. A press nothing takes is handed back as a
  `navigate-back` rather than dropped.

Both are optional fields on frames the other side already reads, so an old
shell never hears a claim and an old page is never sent a press; each pops as
it does today. No protocol bump and no stream opcode.

`bridge-host.ts` was at its line cap, so the notify forwarder moves to
`bridge-host-notify.ts` unchanged.

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

* fix(mobile): Android Back closes the sheet on the page, not the screen

Inside the shell's page every sheet early-returned on web, so one press left
the session route with the sheet still open. The drawer, the right drawer and
the file-preview prompt now claim the key through one seam on both platforms:
`use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on
the shell's. All sixteen session sheets render through `MountedBottomDrawer`,
so the one claim there covers every one of them, and a census fails if a sheet
bypasses it.

`route-handoff.web.ts` claims while the page grew a stack of its own, and
hands the press back when it did not.

The shell takes the key off the navigator only while a claim is live: Android
gets a `hardwareBackPress` handler that returns the host's own answer, iOS
loses the stack's swipe-back. The claim is cleared on `document-started`, on a
remount, on a new `ready`, on anything that takes the generation off screen,
on the page's `close` and on dispose.

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

* test(config): a Back press closes a sheet on the real bundle

The unit suites reach both halves of the lane but never the two together on a
document a browser rendered. The render rig can now post a `back` frame, and
the drawer check opens the Filter sheet, reads the claim off the notify list,
sends one press and pins that the sheet closed with no `navigate-back` behind
it. Red without the drawer's claim: the claim never arrives.

Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is
shared with the native app and mounts under no page provider in a bare tree,
where `usePageBridgeClient` threw; the seam now reads the bridge through
`usePageBridgeClientIfPresent` and claims nothing without one.

Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route
handoff, `bridge-page-back.ts` through the envelope.

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

* refactor(mobile): mirror the Back seam's latest values from an effect

Three `ref.current = …` writes sat in render, which React replays and
discards. Each moves into a dependency-list-free effect declared ahead of the
registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already
uses for the same reason: the caller rebuilds the value every render, so there
is nothing to depend on, and `useRef` seeds the first mount. The registration
still keys on the claim alone, so a rebuilt handler re-registers nothing.

The web seam's test drops its two type assertions for a named fixture type.

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

* fix(mobile): the page says its Back claim again on every init

The claim was edge-triggered and the shell forgets on purpose: it drops the
claim answering every `ready`, and a host rebuilt under a live page — a client
swap through forceReconnect, which leaves the WebView mounted — starts with
none at all. A document still holding a sheet was then unknown to the shell,
and the next press popped the screen out from under it.

`init` is the shell saying it is here now, so the page answers each one with
the state rather than with a transition. Posted after the session has taken
the frame, so the gate reads that `init`'s own `accepts` and a shell that
never named the claim still hears nothing.

Nothing is said while nothing is held. Every `init` answering a `ready` comes
from a host that dropped the claim first, so it already holds false; the only
other one carries a rewritten route, where a stale true needs a `false` the
page posted to have never left, and a port that refused that frame refuses
this one too.

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

* fix(mobile): a rebuilt host keeps the session's Back claim

A host is rebuilt when the client under it changes, and the page document does
not move: the WebView stays mounted, the session id holds, and the page is
never told. The rebuilt host started with no claim and no `accepts`, so it
refused every press and the navigator popped the screen out from under an open
sheet. Two clients on the same generation leave the page nothing to refuse, so
nothing made it re-ask and re-assert.

What the page declared and what it is holding are facts about the session, the
way `sessionEstablished` already is. `createBridgeHostBack` takes them as a
seed, `readSessionBack()` hands them on, and the hook holds them stamped with
the session so a record left by one never seeds the next.

`dispose()` no longer reports the claim gone: a host retiring is not a
document ending, and that report was the thing taking the key off a live
sheet. Every reset path is unchanged and still has its own case — the page's
`ready`, its `close`, and the session's own store for `document-started`,
`remounted` and anything that takes the generation off screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 16:21:26 -04:00

373 lines
15 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,
readBridgeBackNames,
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
let backNames = null
beforeAll(async () => {
if (!bundles) {
return
}
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
backNames = await readBridgeBackNames()
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)
}
})
/** Every notify the page posted, by name, so a case can say what crossed and what did not. */
function notifyNames() {
return globalThis.__orcaRenderCheckNotifies.map((frame) => frame.name)
}
/**
* The defect on the real bundle in a real browser: Android Back with a sheet open.
*
* Chromium only. Android's WebView is Chromium, and the key exists nowhere else — a WebKit arm
* would be pinning a platform that has no hardware Back at all. What the unit suites cannot reach
* is here: the claim leaving a mounted page over the real channel, and the press closing a sheet
* that a real Reanimated animation put on screen.
*/
describeDrawer('the device Back key reaching a sheet on the page', () => {
it('closes the sheet instead of leaving the screen', async () => {
const browser = await ENGINES[0].launch()
try {
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(installShellDouble, {
version: bridgeVersion,
sessionId: 'render-check-session',
buildId: 'render-check-build',
route: { pathname: HOST_ROUTE },
host: SHELL_HOST,
storage: {},
faultGrant,
// What a shell that knows the lane offers, and the frame it would send. Without the first
// the page posts no claim at all, which is the older-shell arm the unit suites pin.
accepts: [backNames.claim],
backFrame: backNames.frame
})
await page.goto(`${origin}/`, { waitUntil: 'load' })
await page.waitForFunction(
() => document.documentElement.dataset.orcaWebEntry === 'mounted',
{ timeout: 30_000, polling: 250 }
)
// Nothing is claimed by a page with no sheet open: the key stays the shell's, which is what
// leaves today's pop as the fallback.
expect(await page.evaluate(notifyNames)).not.toContain(backNames.claim)
const chip = await page.waitForFunction(centreOf, 'Filter', {
timeout: 30_000,
polling: 250
})
await page.mouse.click(...Object.values(await chip.jsonValue()))
await page.waitForFunction(
() => document.querySelector('[aria-label="Dismiss drawer"]') !== null,
{ timeout: 10_000, polling: 100 }
)
const claimed = await page.waitForFunction(
(name) =>
globalThis.__orcaRenderCheckNotifies.find(
(frame) => frame.name === name && frame.claimed === true
) ?? null,
backNames.claim,
{ timeout: 10_000, polling: 100 }
)
expect(await claimed.jsonValue(), errors.join(' | ')).toMatchObject({ claimed: true })
await page.evaluate(() => {
globalThis.__orcaRenderCheckSendBack()
})
const closed = await page
.waitForFunction(() => document.querySelector('[aria-label="Dismiss drawer"]') === null, {
timeout: 10_000,
polling: 100
})
.then(() => true)
.catch(() => false)
expect(
closed,
`${errors.join(' | ')} | ${JSON.stringify(await page.evaluate(notifyNames))}`
).toBe(true)
// The press was spent here. A page that had handed it back would have asked the shell to pop
// the screen, which is the defect this lane is about: the sheet closes and the session route
// goes with it.
expect(await page.evaluate(notifyNames)).not.toContain(backNames.navigateBack)
// And the key goes back to the shell with the sheet, so the next press leaves the screen.
const claims = await page.evaluate(
(name) =>
globalThis.__orcaRenderCheckNotifies
.filter((frame) => frame.name === name)
.map((frame) => frame.claimed),
backNames.claim
)
expect(claims).toEqual([true, false])
expect(errors).toEqual([])
await page.close()
} finally {
await browser.close()
}
}, 120_000)
})