Files
orca/config/scripts/mobile-web-app-source-control-keyboard.test.mjs
T
Jinwoo Hong 5b6a857e41 fix(mobile): route the bottom drawer's keyboard through the platform seam (OTA phase C follow-up) (#22556)
* fix(mobile): route the bottom drawer's keyboard through the platform seam

Fill-mode sheets called Keyboard.metrics() directly, which react-native-web
does not implement, so opening one on the page threw and the shell
re-downloaded the workspace. The drawer now reads useSoftKeyboard, whose
native half seeds from metrics() and carries the event duration, and whose
web half answers from the window (duration 0). The fill/content-sized seed
rule and resolveBottomDrawerKeyboardInset are unchanged. A census keeps
Keyboard.metrics/addListener inside the seam plus the tab-sheet hide wait.

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

* test(config): retire the drawer's exemption from the page keyboard census

The bottom drawer now reads the keyboard seam, so no module in the
source-control or review closures names react-native-web's Keyboard stub.
The census also flags Keyboard.metrics, which the stub lacks.

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

* refactor(mobile): give the drawer an imperative keyboard pair from the seam

The seam now exports subscribeSoftKeyboard and currentSoftKeyboardHeight
beside its hooks. The drawer's effect is back to its original shape with
only its Keyboard calls swapped for the pair, and useSoftKeyboard is back
to {height, visible} with no metrics() seed. Seeding every consumer opened
an iOS window between willHide and didHide where metrics() still reads
open. The web pair answers from visualViewport, so it stays silent inside
the shell and lifts sheets in a plain mobile browser.

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

* fix(mobile): start the web keyboard subscription from the current strip

A keyboard already covering the page when subscribeSoftKeyboard attached
never produced onHide when it closed, so the occlusion hook and a seeded
fill sheet stayed lifted. Outside the shell only.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 19:46:52 -04:00

99 lines
4.6 KiB
JavaScript

/**
* What the source-control hub and the diff review page measure a keyboard with.
*
* react-native-web's `Keyboard` is a stub: `addListener` returns a subscription that never fires
* and `isVisible()` is always false. A module inside the page that waits for `keyboardDidShow`
* waits for the life of the document, and the software keyboard covers whatever is at the bottom
* of it — the hub's commit bar and the review note composer, both of which are text entry.
*
* So the rule is the seam, not the two call sites it has today: `platform/keyboard-occlusion` is
* the one module in either closure allowed to name the stub, and it has a `.web.ts` sibling that
* reads `visualViewport` instead.
*/
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
import { mobileWebAppRouteClosure } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile/', import.meta.url))
const describeClosure = mobileWebAppDependenciesPresent() ? describe : describe.skip
const HUB = 'app/h/[hostId]/source-control/[worktreeId].tsx'
const REVIEW = 'app/h/[hostId]/review/[worktreeId].tsx'
const SEAM = 'src/platform/keyboard-occlusion.web.ts'
/**
* The seam itself, which is the one place allowed to name the stub.
*
* The two files by name rather than everything under `src/platform/`: a later
* `src/platform/<something>.web.ts` that subscribed to `Keyboard` directly would be the same
* defect this census exists for, and a directory-wide exemption would wave it through.
*/
const SEAM_FILES = ['src/platform/keyboard-occlusion.ts', 'src/platform/keyboard-occlusion.web.ts']
/**
* `rootDir` is a parameter for the planted case below, which must not write into the real tree:
* `mobile-web-app-web-overrides.test.mjs` lists `mobile/src` in a parallel worker and would see a
* planted file as an unlisted `.web.*` override. `findWebSiblings(rootDir)` takes a root for the
* same reason.
*/
function keyboardSubscribers(closure, rootDir = mobileDir) {
return closure.local
.filter((file) => !SEAM_FILES.includes(file))
.filter((file) => {
try {
const source = readFileSync(join(rootDir, file), 'utf8')
// `metrics()` too: the stub has none, so a page module calling it throws on the spot.
return source.includes('Keyboard.addListener') || source.includes('Keyboard.metrics')
} catch {
return false
}
})
.sort()
}
describeClosure(
'the keyboard the source-control and review pages measure',
() => {
it.each([HUB, REVIEW])('measures it through the seam and nowhere else: %s', async (route) => {
const closure = await mobileWebAppRouteClosure(route)
expect(keyboardSubscribers(closure)).toEqual([])
})
it.each([HUB, REVIEW])('carries the seam, so the rule is not vacuous: %s', async (route) => {
// Without this an empty subscriber list would also be what a closure reaching no keyboard
// code at all produces, and the census would pass against a page that measures nothing.
const closure = await mobileWebAppRouteClosure(route)
expect(closure.local).toContain(SEAM)
})
it('names a module under src/platform that is not the seam', async () => {
// The exemption is the two seam files, not their directory: a planted subscriber beside them
// is named, which a `startsWith('src/platform/')` filter would have let through.
//
// Under mkdtemp rather than in `mobile/src/platform/`: the overrides census walks that tree
// in a parallel worker, and a planted `.web.ts` there is an unlisted override to it.
const root = mkdtempSync(join(tmpdir(), 'orca-keyboard-census-'))
const subscriber =
'import { Keyboard } from "react-native"\nKeyboard.addListener("x", () => {})\n'
try {
mkdirSync(join(root, 'src', 'platform'), { recursive: true })
// The seam files carry the call too, so the empty result for them is the name exemption
// doing the work rather than the two files happening not to subscribe.
for (const file of ['src/platform/other.web.ts', ...SEAM_FILES]) {
writeFileSync(join(root, file), subscriber)
}
expect(
keyboardSubscribers({ local: ['src/platform/other.web.ts', ...SEAM_FILES] }, root)
).toEqual(['src/platform/other.web.ts'])
} finally {
rmSync(root, { recursive: true, force: true })
}
})
},
240_000
)