Files
orca/config/scripts/mobile-set-native-props-web-siblings.test.mjs
T
Jinwoo Hong 9a25e318f6 fix(mobile): write live-input text through a seam the page can honour (#22189)
* fix(mobile): write live-input text through a seam the page can honour

On React Native Web a TextInput ref is the DOM node itself, so
`setNativeProps` does not exist. The two terminal live-input hooks called
it on `liveInputRef.current`, and the session route reaches one of them
from a mount effect, so the write was a TypeError that faulted the whole
page: the shell tore the view out, the terminal surface stayed 0x0, the
IME never opened and the accessory bar did nothing.

Both now write through `terminal-live-input-text-write.ts`, whose `.web.ts`
sibling sets `value` on the `<input>` or `<textarea>` RN Web renders. That
covers the case React has no commit to make, which is where an interrupted
IME composition leaves the field.

The browser oracle is new: the session render check cannot reach this
defect, because its shell double answers no RPC, so no terminal handle
exists, `liveInputEnabled` is false, the field never mounts and the write
is skipped by its own optional chain. The new check mounts both hooks
against a real field on the page bundle instead.

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

* test(config): fence every setNativeProps write with a web sibling

Fenced by absence rather than by a list of approved callers: any mobile
source module that calls `setNativeProps` must ship a `.web.ts(x)` for the
page bundler to resolve in its place, and no web sibling may make the call
itself. Run against the two hooks as they were, it names both.

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

* docs(mobile): trim the live-input seam's comments to what is not obvious

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

* fix(mobile): move the settings scroll lock into a seam, and fence the route tree

The census scanned `mobile/src` only, so it read the terminal-settings
screen's `setNativeProps({ scrollEnabled })` as absent. It scans `mobile/app`
too now, under a stricter rule: a route file may not make the call at all,
because expo-router registers a `.web.tsx` beside a route as a second route
rather than as a platform sibling, so a route cannot own a platform split.

The one offender it named moves into `src/terminal/terminal-settings-scroll-lock.ts`,
beside the styles module that already owns this screen. Measured rather than
assumed: RN Web puts an `HTMLDivElement` in the ScrollView ref with no
`setNativeProps` at all, and drives the scroller's overflow from a generated
class with nothing inline, so the sibling locks with an inline `overflowY`
and unlocks by clearing it. `overflowY` rather than `touch-action`, which
stops a touch drag but leaves the wheel scrolling.

The seam does not enter the session route's closure — terminal-settings is
not under `app/h` — so the module pin is unmoved at 4270, re-measured.

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

* test(mobile): read the absent setNativeProps with `in`, not Reflect.get

`in` walks the prototype chain, so the claim that RN Web has no such method
to inherit is the same one, and the anti-slop gate has no dynamic read to
object to.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-22 06:15:34 -04:00

201 lines
8.5 KiB
JavaScript

import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join, relative } from 'node:path'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
/**
* Every `setNativeProps` write the mobile app makes, and the web sibling it owes.
*
* `setNativeProps` is a React Native host-component method. On React Native Web a ref is the DOM
* node itself and has no such method, so the call is not a write that does nothing — it is a
* `TypeError`. Where it is made from a render or an effect, that throw reaches the page's fault
* boundary and takes the whole screen down; the shell then tears the view out from under the
* route, which is how one imperative write becomes a 0x0 terminal and a keyboard that never
* opens.
*
* Fenced by absence rather than by a list of approved callers: a module that starts writing this
* way is held to the rule without anyone remembering to add it here. The rule names the file
* rather than the call, because a `.web.ts(x)` sibling is what the page bundler resolves in its
* place, and that substitution is per module.
*
* Both trees the bundler resolves out of are scanned, and `app` is judged more strictly: a route
* file may not make the call at all, because expo-router registers a `.web.tsx` beside a route as
* a second route rather than as a platform sibling, so the split has to live in `src`.
*
* Two offences this caught, both invisible natively and both now fixed by one seam:
* `use-terminal-live-pending-input-flush.ts` cleared the live-input field from the session route's
* mount effect, and `use-terminal-live-accessory-input-commit.ts` wrote the shortened text into it
* when the accessory bar erased a character. Both now call
* `src/terminal/terminal-live-input-text-write.ts`, which ships the sibling.
*/
const mobileDir = join(fileURLToPath(new URL('../../', import.meta.url)), 'mobile')
/**
* Both trees the page bundler resolves modules out of. `app` is the route tree the shell serves,
* and it is judged by a stricter rule: expo-router registers a `.web.tsx` beside a route as a
* second route rather than as a platform sibling, so a route file cannot own a platform split at
* all and the write has to move into a `src` module that can.
*/
const SCANNED = ['src', 'app']
const SOURCE = /\.tsx?$/
const WEB_SIBLING = /\.web\.tsx?$/
const TEST = /\.(test|spec)\.tsx?$/
/** The call, not the word: a docstring naming it is not a write. */
const NATIVE_WRITE = /\.setNativeProps\(/
async function listFiles(directory) {
const out = []
for (const entry of await readdir(directory, { withFileTypes: true })) {
if (entry.name === 'node_modules') {
continue
}
const entryPath = join(directory, entry.name)
if (entry.isDirectory()) {
out.push(...(await listFiles(entryPath)))
} else if (entry.isFile() && SOURCE.test(entry.name) && !TEST.test(entry.name)) {
out.push(entryPath)
}
}
return out
}
const posix = (path) => path.split('\\').join('/')
const exists = (path) =>
readFile(path).then(
() => true,
() => false
)
/** Takes the root so the census can be run against a scratch tree and shown to fail. */
export async function nativePropsWriteSites(rootDir) {
const found = []
for (const tree of SCANNED) {
const directory = join(rootDir, tree)
if (!existsSync(directory)) {
continue
}
for (const file of await listFiles(directory)) {
if (NATIVE_WRITE.test(await readFile(file, 'utf8'))) {
found.push(posix(relative(rootDir, file)))
}
}
}
return found.sort()
}
/** Every `src` module that writes this way and has no `.web.ts(x)` to be replaced by. */
export async function nativePropsWritesWithoutWebSibling(rootDir) {
const offenders = []
for (const site of await nativePropsWriteSites(rootDir)) {
if (WEB_SIBLING.test(site) || !site.startsWith('src/')) {
continue
}
const base = join(rootDir, site).replace(SOURCE, '')
const hasSibling = (await exists(`${base}.web.ts`)) || (await exists(`${base}.web.tsx`))
if (!hasSibling) {
offenders.push(site)
}
}
return offenders
}
/** Every route file that writes this way, which no sibling can rescue. */
export async function routeTreeNativePropsWrites(rootDir) {
return (await nativePropsWriteSites(rootDir)).filter((site) => site.startsWith('app/'))
}
describe('the setNativeProps writes the mobile app makes', () => {
it('gives every one of them a web sibling for the page to resolve instead', async () => {
expect(await nativePropsWritesWithoutWebSibling(mobileDir)).toEqual([])
})
it('makes none of them from a route file, which can carry no platform sibling', async () => {
expect(await routeTreeNativePropsWrites(mobileDir)).toEqual([])
})
it('makes none of them from a web sibling, where the method does not exist at all', async () => {
// The other half of the same rule. A sibling that kept the native call would satisfy the case
// above — the file it replaces has one — and still throw on the page.
expect(
(await nativePropsWriteSites(mobileDir)).filter((site) => WEB_SIBLING.test(site))
).toEqual([])
})
it('reads writes at all, so the two rules above are not vacuous', async () => {
// An empty offender list is also what a tree with no `setNativeProps` in it produces. These
// are the three seams that own every such write today, each beside its own `.web.ts`, and
// the list spans both scanned trees so `app` having none is a reading rather than a gap.
expect(await nativePropsWriteSites(mobileDir)).toEqual([
'src/browser/browser-frame-layer-paint.ts',
'src/terminal/terminal-live-input-text-write.ts',
'src/terminal/terminal-settings-scroll-lock.ts'
])
})
})
async function withScratch(run) {
const scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-set-native-props-'))
try {
return await run(scratch)
} finally {
await rm(scratch, { recursive: true, force: true })
}
}
async function plant(scratch, file, source) {
await mkdir(join(scratch, file, '..'), { recursive: true })
await writeFile(join(scratch, file), source, 'utf8')
}
const WRITES = 'export const paint = (ref) => ref.current?.setNativeProps({ text: "" })\n'
// A census that happens to be run against a tree with no offence in it passes for the wrong
// reason. These plant the shapes it claims to judge and show what it would report.
describe('the census scan', () => {
it('names a native module that writes with no sibling beside it', async () => {
await withScratch(async (scratch) => {
await plant(scratch, 'src/terminal/use-terminal-live-pending-input-flush.ts', WRITES)
await plant(scratch, 'src/terminal/use-terminal-live-accessory-input-commit.ts', WRITES)
expect(await nativePropsWritesWithoutWebSibling(scratch)).toEqual([
'src/terminal/use-terminal-live-accessory-input-commit.ts',
'src/terminal/use-terminal-live-pending-input-flush.ts'
])
})
})
it('clears a native module once its sibling is on disk, for .ts and for .tsx', async () => {
await withScratch(async (scratch) => {
await plant(scratch, 'src/terminal/write.ts', WRITES)
await plant(scratch, 'src/terminal/write.web.ts', 'export const paint = () => {}\n')
await plant(scratch, 'src/browser/Pane.tsx', WRITES)
await plant(scratch, 'src/browser/Pane.web.tsx', 'export const paint = () => {}\n')
expect(await nativePropsWritesWithoutWebSibling(scratch)).toEqual([])
})
})
it('names a route file that writes, and is not satisfied by a sibling beside it', async () => {
await withScratch(async (scratch) => {
await plant(scratch, 'app/terminal-settings.tsx', WRITES)
// Planted to show the stricter rule is not the sibling rule wearing a different name: a
// route with a `.web.tsx` next to it is still an offence, because expo-router reads that
// file as a second route.
await plant(scratch, 'app/terminal-settings.web.tsx', 'export default () => null\n')
expect(await routeTreeNativePropsWrites(scratch)).toEqual(['app/terminal-settings.tsx'])
expect(await nativePropsWritesWithoutWebSibling(scratch)).toEqual([])
})
})
it('skips tests, which write this way to prove the native call throws', async () => {
await withScratch(async (scratch) => {
await plant(scratch, 'src/terminal/write.test.ts', WRITES)
await plant(scratch, 'src/terminal/write.spec.tsx', WRITES)
expect(await nativePropsWriteSites(scratch)).toEqual([])
})
})
})