Files
orca/mobile/web-entry/index.tsx
T
Jinwoo Hong ba858ee446 feat(mobile): the keyboard covers the page like a native screen, and the shell says its height (#23110)
The shell no longer shortens the WebView for the keyboard; it publishes the keyboard height like the safe-area insets, so native's keyboard lift, refit hold and dismiss key run on the page unchanged. Keyboard and inset arithmetic read the shell's OS through a host-os seam. One page-version floor (manifest pageVersion, shell floor 1) replaces per-feature accept negotiation; a page below the floor gets the existing update wall, a desktop with no bundle keeps native screens. iOS shell drops the form accessory bar and its own keyboard observers. Native session screens untouched.
2026-09-28 04:07:35 -04:00

153 lines
7.1 KiB
TypeScript

// Route A web entry: mounts the phone's h/[hostId] route tree on react-native-web.
// Built by `build:mobile-web` into the packaged bundle dir; mounted only by a build with
// `EXPO_PUBLIC_MOBILE_SHELL=ota`.
import { useEffect, type PropsWithChildren } from 'react'
import { createRoot } from 'react-dom/client'
import { ExpoRoot } from 'expo-router'
import type { BridgeRpcClient } from '../src/mobile-web-shell/bridge/bridge-rpc-client'
import {
bootstrapShellPage,
createShellPageClient,
stampPageMountState,
type PageMountTarget
} from '../src/mobile-web-shell/bridge/page-bootstrap'
import { publishPageStorage } from '../src/mobile-web-shell/bridge/page-async-storage'
import {
RouteScreenPaintProvider,
createRouteScreenPaintReporter
} from '../src/mobile-web-shell/bridge/page-first-paint'
import { PageFaultBoundary } from '../src/mobile-web-shell/bridge/page-fault-boundary'
import { publishPageHostProfile } from '../src/mobile-web-shell/bridge/page-host-profile'
import { publishExternalLinkOpener } from '../src/platform/external-link.web'
import { publishHapticsNotifier } from '../src/platform/haptics.web'
import { publishShellKeyboardSource } from '../src/platform/keyboard-occlusion.web'
// Named with its extension: this entry is the web build's and the provider it needs is the web
// sibling's, which takes the page's client. The screens below still import `./client-context`
// and reach the same module, because the builder resolves both specifiers to the same file.
import { RpcClientProvider } from '../src/transport/client-context.web'
// Body replaced at build time: esbuild has no require.context, so the builder synthesizes one.
import routeContext from './route-manifest'
// The route tree starts at app/h, below the native root layout that owns the provider, so the
// page supplies it here through ExpoRoot's own wrapper rather than mounting the native shell.
// No suspense boundary: expo-router wraps every screen in its own, which is what catches the
// route chunks the manifest defers. A chunk that never arrives is a rejection rather than a wait,
// and that is the boundary below's, not suspense's.
// A factory because the client is not in scope until `init` lands, and ExpoRoot takes a component.
function createRootProviders(client: BridgeRpcClient, target: PageMountTarget) {
// A commit is not a paint, and an unpainted WebView shows the surface behind it and nothing else,
// so the shell keeps its own frame over this document until the second frame lands.
const reportRouteScreenPaint = createRouteScreenPaintReporter(
{
requestFrame: (callback) => requestAnimationFrame(callback),
cancelFrame: (handle) => {
cancelAnimationFrame(handle)
}
},
() => {
client.notifyPagePainted()
}
)
return function RootProviders({ children }: PropsWithChildren) {
// Effects run child-first, so 'mounted' lands only after the router tree below this wrapper
// has committed. That commit can be the suspense fallback of a route chunk still arriving,
// which is why the paint is reported from the screen and not from here.
useEffect(() => {
stampPageMountState(target, 'mounted')
}, [])
return (
<RpcClientProvider client={client}>
<RouteScreenPaintProvider report={reportRouteScreenPaint}>
{children}
</RouteScreenPaintProvider>
</RpcClientProvider>
)
}
}
/**
* The whole page for a shell that opened it and then named no screen.
*
* Built as elements rather than markup, and outside React: the route tree is exactly what cannot
* be mounted here, and a panel that needed it would be a second way to fail. The copy names the
* one thing that fixes it, because nothing on this device will.
*/
function renderShellTooOldPanel(container: HTMLElement): void {
const panel = document.createElement('div')
panel.setAttribute('role', 'alert')
panel.style.cssText =
'font:16px/1.5 system-ui,-apple-system,sans-serif;color:#e6e6e6;background:#141414;' +
'min-height:100vh;display:flex;flex-direction:column;align-items:center;' +
'justify-content:center;gap:8px;padding:24px;text-align:center'
const title = document.createElement('div')
title.style.cssText = 'font-weight:600'
title.textContent = 'Update Orca to open this workspace'
const body = document.createElement('div')
body.style.cssText = 'color:#9a9a9a;font-size:14px'
body.textContent = 'This version of the app cannot open the workspace it downloaded.'
panel.append(title, body)
container.replaceChildren(panel)
}
const container = document.getElementById('root')
if (!container) {
throw new Error('[orca-mobile-web-app] #root missing')
}
const target = document.documentElement
stampPageMountState(target, 'started')
bootstrapShellPage({
target,
client: createShellPageClient(),
replaceUrl: (href) => {
history.replaceState(null, '', href)
},
mount: (client, session) => {
// Before the first render, because both are read from effects that run on it: the host store is
// a plain async function with no provider above it, and the first list paints its pins.
publishPageHostProfile(session.host)
// Same reason, and the same shape: the seam is a plain function in render trees the provider
// does not wrap, so the client's notify is published rather than read from context.
publishExternalLinkOpener((url) => client.notifyExternalLink(url))
// The same shape again, and for the same reason: every haptic on this page is played from a
// plain function inside a row's press handler, which no provider wraps.
publishHapticsNotifier((kind) => client.notifyHaptics(kind))
// The shell's keyboard height, read by plain functions as well as hooks; 0 while it is closed.
publishShellKeyboardSource({
read: () => client.getShellSession()?.keyboardInset ?? 0,
subscribe: client.onKeyboardInsetUpdate
})
// Scoped to the host `init` named: with none, no key is writable, which is the right answer
// for a shell too old to say whose list this is.
publishPageStorage(
session.storage,
(key, value) => client.notifyStorageWrite(key, value),
session.host?.id ?? '',
session.route?.pathname ?? '',
session.storageOversize
)
createRoot(container).render(
// Above `ExpoRoot`, not inside its wrapper: a route this bundle cannot resolve or import
// throws where the router renders it, and a boundary below the router never sees that.
<PageFaultBoundary
onFault={(error) => {
client.notifyPageFault(error)
}}
>
<ExpoRoot
context={routeContext}
// The same URL the line above just wrote, handed over rather than left to be read:
// ExpoRoot snapshots `window.location.href` when its module is imported, which is before
// any frame has crossed the bridge, so what it captured on its own is the `/` the shell
// serves.
location={new URL(window.location.href)}
wrapper={createRootProviders(client, target)}
/>
</PageFaultBoundary>
)
},
refuseUnroutedShell: () => {
renderShellTooOldPanel(container)
}
})