Files
orca/mobile/web-entry
Jinwoo Hong 6f0fb3fe39 feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2) (#21754)
* feat(mobile): paint browser screencast frames through web siblings (OTA phase C, C6.2)

The pane's frame path is written against React Native's native-prop writer, which does
not exist on React Native Web: a ref there is the DOM node, so both writes throw and the
pane never shows a frame. Three `.web.ts` siblings, each for a measured gap.

- The image and layer writes move out of `mobile-browser-frame-state.ts` into
  `browser-frame-layer-paint.ts`, whose sibling paints the frame as a `background-image`
  on the element RN Web sizes and flips the double buffer with one opacity write per
  layer. The pane still never re-renders while it streams.
- A `background-image` write fires no load event, so the offscreen layer would never
  become visible. The sibling arms the flip from an image decode instead, and the flip
  itself is shared with the native `onLoad` path rather than written twice.
- The data URI keeps the base64 the bridge already carried instead of encoding the bytes
  back into the same string. Measured in this tree against the `buffer` shim the page
  bundle resolves: 0.256 ms per frame at 45,815 bytes and 2.61 ms at 463,942, against
  under a microsecond for the carried string.

Per C6 ruling 5 the pane asks for binary frames only when the shell granted the lane, and
renders its existing stream-error state otherwise, so a page never waits on frames a shell
without the encoder cannot send. The grant name is a placeholder until C6.1 reports it.

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

* fix(mobile): name C6.1's binary screencast grant (OTA phase C, C6.2)

C6.1 has decided the name: `screencastBinary`, one camelCase token. Replaces the
placeholder this PR landed with while C6.1 was still choosing.

The placeholder was also unusable, which the test added here would have caught:
`GRANT_NAME_PATTERN` in the manifest contract admits a bare name or a `native.`-prefixed
verb and nothing else, so a route declaring `browser.screencast.binary` would have been
refused by the bundle before any shell saw it, and the pane would have taken its
stream-error branch for a reason no screen could report. The name is now checked against
`MobileWebBundleRouteSchema` itself rather than against a restated regex, with the dotted
spelling as the failing case beside it.

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

* fix(mobile): answer a frame decode for the frame, not the layer (OTA phase C, C6.2)

Round 1 folds on #21754.

The undecodable arm freed the pending slot without checking whose frame had failed, while
the displayable arm checked. Reproduced: frame 2 goes pending on layer 1, frame 3 repoints
the same layer, frame 2's decode rejects and clears the slot layer 1 is holding for frame
3, then frame 3 decodes and the flip is refused because the slot no longer names its
layer. The newest frame sits decoded at opacity 0 behind an older one, and a page that has
gone still sends no further frame to recover with. Web only; native never calls this.

Both arms now answer for the frame they were armed with.

The displayable arm's own guard had no test: deleting it left `src/browser/` and the full
suite green, because the case that exercised it settled both decodes and asserted an end
state both orders produce. The harness now settles one decode at a time, keyed on the
source it was given, and the ordered case reds without the guard.

Also: the paint sibling's opacity test claimed "no re-render" while asserting two style
strings, so it is named for what it checks and the claim is counted where React is —
across ten streamed frames the three state setters are called once each, on the mount
frame. And the overrides allowlist is rebuilt from main's bytes plus the new entries, so
two pre-existing reasons keep their literal em dash instead of a re-serialized escape.

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

* test(mobile): check the frame write against a real react-native-web Image (OTA phase C, C6.2)

Round 2 folds on #21754.

Every test for the web paint sibling handed it a `div > div` of its own making, so the
assumption it rests on — that the host's first element child is the one carrying the
frame — was only ever checked against a shape written to match it. React Native Web also
renders an accessibility `<img>` in there, and a release that reorders those children
would keep all of them green while the pane painted nothing.

One test now renders the real component, asks it which child it painted, and checks the
write lands on that one. Pointing the sibling at `lastElementChild` reds it and leaves the
hand-built cases passing, which is the gap. A second case records what the `<img>` does:
the streaming path writes styles and never props, so it keeps the source it mounted with
for the life of the pane, and that is what a screen reader and the image context menu see.

react-native-web ships no type declarations, so the component comes through
`createRequire`, whose return is `any` at its own signature; the one prop it renders with
is declared rather than asserted, and the file stays inside the tests-typecheck ratchet.

The module docstring also claimed more than the code does. The frame path adds no render,
but a render from any of the pane's other state — address focus, a dialog, the view mode,
zoom — repaints both layers from `renderedFrameSource`, which reads `frameUriRef.current`,
so both land on the newest frame whether or not it has decoded. Native clobbers the same
way through `setNativeProps`. Said plainly, along with what restores the buffering.

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

* docs(mobile): say what a render does to the accessibility image (OTA phase C, C6.2)

pullfrog is right, and the test carried the same wrong claim. The note said the hidden
`<img>` keeps the frame it mounted with for the life of the pane, two paragraphs after
saying a render from the pane's other state passes `renderedFrameSource` as `source` —
and React Native Web derives that image's `src` from the same prop it paints the
background from, so the first such render moves it.

Measured here rather than reasoned about: rendering the real component, writing a frame
imperatively, then re-rendering with a new source moves the `src` and leaves the
background where the imperative write put it. The two halves are now two cases, named for
what each one shows, and the note says the streaming writes never touch it while a render
does — so it holds the frame the pane last rendered with, not the one on screen.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-20 04:30:15 -04:00
..